[feat] negodata/backend: 상품·협력사·견적 도메인 CRUD + delivery_type 코드화 + 공용 /v1/enums
- item/supplier/quotation/quotation_setting CRUD·service·router 추가 - item protocol delivery_type str→int (ERD/스키마 SMALLINT 일치) - DeliveryType enum + 한글 라벨, 공용 GET /v1/enums (도메인 코드 메타데이터) - CompanyBrief → CompanyData 로 *Data 네이밍 통일 - CORS: WebServerConfig.client_url(단일) 도입 (config_models/router) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7e2d5e5978
commit
1f778ab975
@ -1,26 +1,161 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy import Column, Integer, SmallInteger, BigInteger, Numeric, Float, String, Boolean, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
from common.enums import DBType
|
||||
from common.enums import DBType, UserStatus, UserRole, CompanyStatus
|
||||
|
||||
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
|
||||
MAIN_BASE = declarative_base()
|
||||
|
||||
|
||||
class tbl_account(MAIN_BASE):
|
||||
# 모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택).
|
||||
# 공통 mixin
|
||||
# DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
|
||||
def _utc_now_sql():
|
||||
return text("(now() AT TIME ZONE 'utc')")
|
||||
|
||||
|
||||
class _DBTypeMixin:
|
||||
"""모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택)."""
|
||||
|
||||
@staticmethod
|
||||
def DBType():
|
||||
return DBType.MAIN.value
|
||||
|
||||
|
||||
# ERD 공통 컬럼(created/updated/deleted). negodata 도메인 테이블은 단일 MAIN DB 에서 soft-delete 를 쓴다.
|
||||
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())
|
||||
deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False)
|
||||
|
||||
|
||||
# 스캐폴드 예시 계정 (negosium 골격 유산)
|
||||
# negodata 인증은 ERD 의 users/companies 를 쓴다(아래). tbl_account 는 스캐폴드
|
||||
# 테스트 호환을 위해 남겨둔다 (negodata_db 의 postgres-init 이 생성).
|
||||
class tbl_account(_DBTypeMixin, MAIN_BASE):
|
||||
__tablename__ = "tbl_account"
|
||||
|
||||
uid = Column(Integer, primary_key=True, autoincrement=True)
|
||||
id = Column(String(45), nullable=False, unique=True) # 로그인 ID. 중복 가입 방지 위해 unique.
|
||||
pw = Column(String(255), nullable=False, default="") # bcrypt 해시 저장
|
||||
id = Column(String(45), nullable=False, unique=True)
|
||||
pw = Column(String(255), nullable=False, default="")
|
||||
nickname = Column(String(45), nullable=False, default="")
|
||||
is_blocked = Column(Boolean, nullable=False, default=False)
|
||||
# PostgreSQL UTC now: now() 는 timestamptz 이므로 utc 로 변환해 timestamp 로 저장.
|
||||
last_login_at = Column(DateTime, nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"))
|
||||
create_at = Column(DateTime, server_default=text("(now() AT TIME ZONE 'utc')"))
|
||||
last_login_at = Column(DateTime, nullable=False, server_default=_utc_now_sql())
|
||||
create_at = Column(DateTime, server_default=_utc_now_sql())
|
||||
|
||||
|
||||
# ERD 도메인 모델 (negosium.sql 기준, UUID PK)
|
||||
class companies(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "companies"
|
||||
|
||||
company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(100), nullable=False)
|
||||
business_number = Column(String(30), nullable=True)
|
||||
code = Column(Integer, nullable=True) # 내부 인덱스용
|
||||
representative_name = Column(String(50), nullable=True)
|
||||
email = Column(String(255), nullable=True)
|
||||
contact_number = Column(String(20), nullable=True)
|
||||
website_url = Column(String(255), nullable=True)
|
||||
industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT)
|
||||
status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus
|
||||
|
||||
|
||||
class users(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "users"
|
||||
|
||||
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
company_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
id = Column(String(20), nullable=False, unique=True, index=True) # 로그인 아이디
|
||||
password = Column(String(255), nullable=False) # bcrypt 해시 (ERD VARCHAR(30)→255 확장)
|
||||
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())
|
||||
status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) # UserStatus
|
||||
role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) # UserRole
|
||||
|
||||
|
||||
class items(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "items"
|
||||
|
||||
item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
company_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 등록한 유저
|
||||
|
||||
name = Column(String(100), nullable=False)
|
||||
code = Column(String(30), nullable=True)
|
||||
category = Column(String(255), nullable=True)
|
||||
category_type = Column(Integer, nullable=False, default=1) # 카테고리 탐색용 자동 증가 숫자
|
||||
|
||||
image_url = Column(String(255), nullable=True)
|
||||
model_name = Column(String(100), nullable=True)
|
||||
spec = Column(String(255), nullable=True)
|
||||
manufacturer = Column(String(50), nullable=True)
|
||||
made_in = Column(String(100), nullable=True)
|
||||
|
||||
price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT
|
||||
internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용
|
||||
|
||||
moq = Column(String(50), nullable=True) # 최소 주문 수량
|
||||
lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간
|
||||
quantity_unit = Column(String(50), nullable=True) # 단위 라벨(자유입력): EA/BOX/SET/ROLL ...
|
||||
delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 코드
|
||||
vat_yn = Column(Boolean, nullable=True)
|
||||
delivery_fee_yn = Column(Boolean, nullable=True)
|
||||
|
||||
|
||||
class suppliers(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
supplier_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
company_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 등록한 유저
|
||||
|
||||
name = Column(String(100), nullable=False)
|
||||
code = Column(String(20), nullable=True)
|
||||
manager_name = Column(String(50), nullable=True)
|
||||
manager_email = Column(String(255), nullable=True)
|
||||
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
|
||||
priority = Column(String(10), nullable=True) # True/False 가 아닌 string value 가능
|
||||
|
||||
|
||||
class quotation_settings(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "quotation_settings"
|
||||
|
||||
qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
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) # 한 협상 내 협상카드 사용 횟수
|
||||
|
||||
|
||||
class quotations(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "quotations"
|
||||
|
||||
qt_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
qt_setting_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
version_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
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)
|
||||
round = Column(Integer, nullable=False, default=1) # 재견적 진행 시 증가
|
||||
status = Column(SmallInteger, nullable=False) # 진행 상태 코드
|
||||
start_time = Column(DateTime, nullable=False)
|
||||
end_time = Column(DateTime, 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)
|
||||
|
||||
iteration = Column(Integer, nullable=False, default=0)
|
||||
preferred_sp_yn = Column(Boolean, nullable=True)
|
||||
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
preferred_sp_name = Column(String(20), nullable=True)
|
||||
equal_bid_yn = Column(Boolean, nullable=True)
|
||||
equal_bid_data = Column(JSONB, nullable=True)
|
||||
|
||||
@ -36,6 +36,18 @@ class ErrorType(Enum):
|
||||
ACCOUNT_ALREADY_EXIST = auto()
|
||||
ACCOUNT_BLOCKED_USER = auto()
|
||||
|
||||
# 상품 관련 에러
|
||||
ITEM_NOT_FOUND = 1300
|
||||
|
||||
# 협력사 관련 에러
|
||||
SUPPLIER_NOT_FOUND = 1400
|
||||
|
||||
# 견적 관련 에러
|
||||
QUOTATION_NOT_FOUND = 1500
|
||||
|
||||
# 견적 설정 관련 에러
|
||||
QUOTATION_SETTING_NOT_FOUND = 1600
|
||||
|
||||
|
||||
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
||||
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
|
||||
@ -59,3 +71,65 @@ class DBWRType(Enum):
|
||||
|
||||
DB_READ = 1
|
||||
DB_WRITE = 2
|
||||
|
||||
|
||||
# 도메인 코드값
|
||||
class UserStatus(Enum):
|
||||
"""users.status 코드값."""
|
||||
|
||||
ACTIVE = 1
|
||||
INACTIVE = 2
|
||||
|
||||
|
||||
class UserRole(Enum):
|
||||
"""users.role 코드값."""
|
||||
|
||||
USER = 1
|
||||
MANAGER = 2
|
||||
|
||||
|
||||
class CompanyStatus(Enum):
|
||||
"""companies.status 코드값."""
|
||||
|
||||
ACTIVE = 1
|
||||
INACTIVE = 2
|
||||
|
||||
|
||||
class QuotationType(Enum):
|
||||
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
|
||||
|
||||
RENEGO = 1
|
||||
REQUOTE = 2
|
||||
|
||||
|
||||
class DeliveryType(Enum):
|
||||
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
|
||||
|
||||
PARTNER = 1 # 협력사배송
|
||||
COURIER = 2 # 지정택배배송
|
||||
PICKUP = 3 # 픽업배송
|
||||
|
||||
|
||||
# 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드).
|
||||
ENUM_LABELS = {
|
||||
UserStatus.ACTIVE: "활성",
|
||||
UserStatus.INACTIVE: "비활성",
|
||||
UserRole.USER: "일반",
|
||||
UserRole.MANAGER: "관리자",
|
||||
CompanyStatus.ACTIVE: "활성",
|
||||
CompanyStatus.INACTIVE: "비활성",
|
||||
QuotationType.RENEGO: "재협상",
|
||||
QuotationType.REQUOTE: "재견적",
|
||||
DeliveryType.PARTNER: "협력사배송",
|
||||
DeliveryType.COURIER: "지정택배배송",
|
||||
DeliveryType.PICKUP: "픽업배송",
|
||||
}
|
||||
|
||||
# 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다.
|
||||
DOMAIN_ENUMS = {
|
||||
"user_status": UserStatus,
|
||||
"user_role": UserRole,
|
||||
"company_status": CompanyStatus,
|
||||
"quotation_type": QuotationType,
|
||||
"delivery_type": DeliveryType,
|
||||
}
|
||||
|
||||
@ -48,9 +48,9 @@ class Res_WebPacketProtocol(WebPacketProtocol):
|
||||
class UserInfo(StructModel):
|
||||
"""JWT subject 로 인코딩되는 유저 식별 정보."""
|
||||
|
||||
uid: int
|
||||
id: str
|
||||
nickname: str
|
||||
user_id: str # users.user_id (uuid) — 데이터 스코프 키
|
||||
id: str # users.id (로그인 아이디) — get_me 재조회 키
|
||||
company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__()
|
||||
|
||||
@ -7,6 +7,7 @@ class WebServerConfig(ConfigModel):
|
||||
process_count: int = 1
|
||||
is_ssl: bool = False
|
||||
is_test: bool = False
|
||||
client_url: str = ""
|
||||
|
||||
|
||||
class LogConfig(ConfigModel):
|
||||
|
||||
@ -4,6 +4,8 @@ import os
|
||||
|
||||
os.environ.setdefault("APP_ENV", "local")
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
@ -28,11 +30,31 @@ async def db_engine():
|
||||
engine = create_async_engine(_write_url(main_db_config))
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip
|
||||
await conn.execute(text("TRUNCATE TABLE tbl_account"))
|
||||
# negodata 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
|
||||
await conn.execute(
|
||||
text(
|
||||
"TRUNCATE TABLE tbl_account, users, companies, items, suppliers, "
|
||||
"quotation_settings, quotations RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def company_id(db_engine) -> str:
|
||||
"""테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다.
|
||||
users 는 company_id 를 요구하므로 계정 생성 테스트의 선행 조건이다.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def _dispose_app_engines():
|
||||
"""테스트 세션이 끝날 때 앱 싱글톤 엔진을 정리한다.
|
||||
|
||||
101
negodata/backend/crud/item_crud.py
Normal file
101
negodata/backend/crud/item_crud.py
Normal file
@ -0,0 +1,101 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
# 상품 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트).
|
||||
class IItemCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def search(self, cdb: AsyncSession, company_id, search, category, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_item(self, cdb: AsyncSession, item: items) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_item(self, cdb: AsyncSession, item_id, data: dict) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
|
||||
class ItemCRUD(IItemCRUD):
|
||||
async def search(
|
||||
self, cdb: AsyncSession, company_id, search: Optional[str], category: Optional[str], skip: int, limit: int
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
conditions = [items.deleted == False, items.company_id == company_id] # noqa: E712
|
||||
if search:
|
||||
conditions.append(or_(items.name.ilike(f"%{search}%"), items.code.ilike(f"%{search}%")))
|
||||
if category:
|
||||
conditions.append(items.category == category)
|
||||
where = and_(*conditions)
|
||||
|
||||
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(items).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(items).where(where).order_by(items.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_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
|
||||
try:
|
||||
query = select(items).where(items.item_id == item_id, items.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 add_item(self, cdb: AsyncSession, item: items) -> ErrorType:
|
||||
try:
|
||||
return await DB_SESSION_MNG.insert(cdb, item)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def update_item(self, cdb: AsyncSession, item_id, data: dict) -> ErrorType:
|
||||
try:
|
||||
if not data:
|
||||
return ErrorType.SUCCESS
|
||||
query = update(items).where(items.item_id == item_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
|
||||
|
||||
async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType:
|
||||
try:
|
||||
query = update(items).where(items.item_id == item_id).values(deleted=True, updated_at=GTime.UTC())
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
116
negodata/backend/crud/quotation_crud.py
Normal file
116
negodata/backend/crud/quotation_crud.py
Normal file
@ -0,0 +1,116 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import select, func, and_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
# 견적 CRUD. quotations 테이블에는 company_id 가 없어 회사 스코프는 하지 않는다(토큰 검증만).
|
||||
# user_id 는 생성 시 소유자로 기록만 한다.
|
||||
class IQuotationCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self, cdb: AsyncSession, 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]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_quotation(self, cdb: AsyncSession, quotation: quotations) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
|
||||
class QuotationCRUD(IQuotationCRUD):
|
||||
async def search(
|
||||
self,
|
||||
cdb: AsyncSession,
|
||||
status: Optional[str],
|
||||
type_: Optional[str],
|
||||
start_from: Optional[datetime],
|
||||
start_to: Optional[datetime],
|
||||
skip: int,
|
||||
limit: int,
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
conditions = [quotations.deleted == False] # noqa: E712
|
||||
if status:
|
||||
conditions.append(quotations.status == status)
|
||||
if type_:
|
||||
conditions.append(quotations.type == type_)
|
||||
if start_from:
|
||||
conditions.append(quotations.start_time >= start_from)
|
||||
if start_to:
|
||||
conditions.append(quotations.start_time <= start_to)
|
||||
where = and_(*conditions)
|
||||
|
||||
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(quotations).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(quotations).where(where).order_by(quotations.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_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]:
|
||||
try:
|
||||
query = select(quotations).where(quotations.qt_id == qt_id, quotations.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 add_quotation(self, cdb: AsyncSession, quotation: quotations) -> ErrorType:
|
||||
try:
|
||||
return await DB_SESSION_MNG.insert(cdb, quotation)
|
||||
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:
|
||||
return ErrorType.SUCCESS
|
||||
query = update(quotations).where(quotations.qt_id == qt_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
|
||||
|
||||
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
|
||||
try:
|
||||
query = update(quotations).where(quotations.qt_id == qt_id).values(deleted=True, updated_at=GTime.UTC())
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
104
negodata/backend/crud/quotation_setting_crud.py
Normal file
104
negodata/backend/crud/quotation_setting_crud.py
Normal file
@ -0,0 +1,104 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import select, func, and_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotation_settings
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
# 견적 설정 CRUD. 모든 조회/변경은 user_id 로 스코프된다(유저별 설정).
|
||||
class IQuotationSettingCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def list_by_user(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, list, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, quotation_settings]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_setting(self, cdb: AsyncSession, setting: quotation_settings) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_setting(self, cdb: AsyncSession, qt_setting_id, data: dict) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def soft_delete(self, cdb: AsyncSession, qt_setting_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
|
||||
class QuotationSettingCRUD(IQuotationSettingCRUD):
|
||||
async def list_by_user(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
where = and_(quotation_settings.deleted == False, quotation_settings.user_id == user_id) # noqa: E712
|
||||
|
||||
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(
|
||||
cdb, select(func.count()).select_from(quotation_settings).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(quotation_settings).where(where).order_by(quotation_settings.created_at.desc()),
|
||||
)
|
||||
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_id(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, quotation_settings]:
|
||||
try:
|
||||
query = (
|
||||
select(quotation_settings)
|
||||
.where(quotation_settings.qt_setting_id == qt_setting_id, quotation_settings.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
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 add_setting(self, cdb: AsyncSession, setting: quotation_settings) -> ErrorType:
|
||||
try:
|
||||
return await DB_SESSION_MNG.insert(cdb, setting)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def update_setting(self, cdb: AsyncSession, qt_setting_id, data: dict) -> ErrorType:
|
||||
try:
|
||||
if not data:
|
||||
return ErrorType.SUCCESS
|
||||
query = update(quotation_settings).where(quotation_settings.qt_setting_id == qt_setting_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
|
||||
|
||||
async def soft_delete(self, cdb: AsyncSession, qt_setting_id) -> ErrorType:
|
||||
try:
|
||||
query = (
|
||||
update(quotation_settings)
|
||||
.where(quotation_settings.qt_setting_id == qt_setting_id)
|
||||
.values(deleted=True, updated_at=GTime.UTC())
|
||||
)
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
105
negodata/backend/crud/supplier_crud.py
Normal file
105
negodata/backend/crud/supplier_crud.py
Normal file
@ -0,0 +1,105 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import suppliers
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
# 협력사 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트).
|
||||
class ISupplierCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def search(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, suppliers]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_supplier(self, cdb: AsyncSession, supplier: suppliers) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_supplier(self, cdb: AsyncSession, supplier_id, data: dict) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
|
||||
class SupplierCRUD(ISupplierCRUD):
|
||||
async def search(
|
||||
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
conditions = [suppliers.deleted == False, suppliers.company_id == company_id] # noqa: E712
|
||||
if search:
|
||||
conditions.append(
|
||||
or_(
|
||||
suppliers.name.ilike(f"%{search}%"),
|
||||
suppliers.code.ilike(f"%{search}%"),
|
||||
suppliers.manager_name.ilike(f"%{search}%"),
|
||||
)
|
||||
)
|
||||
where = and_(*conditions)
|
||||
|
||||
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(suppliers).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(suppliers).where(where).order_by(suppliers.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_id(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, suppliers]:
|
||||
try:
|
||||
query = select(suppliers).where(suppliers.supplier_id == supplier_id, suppliers.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 add_supplier(self, cdb: AsyncSession, supplier: suppliers) -> ErrorType:
|
||||
try:
|
||||
return await DB_SESSION_MNG.insert(cdb, supplier)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def update_supplier(self, cdb: AsyncSession, supplier_id, data: dict) -> ErrorType:
|
||||
try:
|
||||
if not data:
|
||||
return ErrorType.SUCCESS
|
||||
query = update(suppliers).where(suppliers.supplier_id == supplier_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
|
||||
|
||||
async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType:
|
||||
try:
|
||||
query = update(suppliers).where(suppliers.supplier_id == supplier_id).values(deleted=True, updated_at=GTime.UTC())
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
@ -5,7 +5,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import tbl_account
|
||||
from common.database.model.models import users, companies
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -16,27 +16,31 @@ from common.utils.gtime import GTime
|
||||
# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다.
|
||||
class IUserCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]:
|
||||
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType:
|
||||
async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType:
|
||||
async def add_user(self, cdb: AsyncSession, user: users) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType:
|
||||
async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
|
||||
pass
|
||||
|
||||
|
||||
class UserCRUD(IUserCRUD):
|
||||
async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]:
|
||||
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
|
||||
try:
|
||||
query = select(tbl_account).where(tbl_account.id == user_id).limit(1)
|
||||
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_account_by_id(ID:{user_id}) failed.")
|
||||
query = select(users).where(users.id == login_id, users.deleted == False).limit(1) # noqa: E712
|
||||
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_user_by_login_id(ID:{login_id}) failed.")
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
if len(row_list) != 1:
|
||||
@ -46,9 +50,9 @@ class UserCRUD(IUserCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType:
|
||||
async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType:
|
||||
try:
|
||||
query = select(tbl_account).where(tbl_account.id == user_id).limit(1)
|
||||
query = select(users).where(users.id == login_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
|
||||
@ -59,17 +63,30 @@ class UserCRUD(IUserCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType:
|
||||
async def add_user(self, cdb: AsyncSession, user: users) -> ErrorType:
|
||||
try:
|
||||
return await DB_SESSION_MNG.insert(cdb, account)
|
||||
return await DB_SESSION_MNG.insert(cdb, user)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType:
|
||||
async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType:
|
||||
try:
|
||||
query = update(tbl_account).where(tbl_account.uid == user_uid).values(last_login_at=GTime.UTC())
|
||||
query = update(users).where(users.user_id == user_id).values(last_accessed_at=GTime.UTC())
|
||||
return await DB_SESSION_MNG.add(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
|
||||
try:
|
||||
query = select(companies).where(companies.company_id == company_id, companies.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
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy>=2.0
|
||||
greenlet
|
||||
asyncpg
|
||||
python-jose[cryptography]
|
||||
bcrypt
|
||||
orjson
|
||||
pydantic>=2.0
|
||||
python-multipart
|
||||
openpyxl
|
||||
|
||||
@ -2,12 +2,19 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
from config.server_configs import web_server_config
|
||||
import router.v1.auth.account
|
||||
import router.v1.item.item
|
||||
import router.v1.supplier.supplier
|
||||
import router.v1.quotation.quotation
|
||||
import router.v1.quotation_setting.quotation_setting
|
||||
import router.v1.enums.enums
|
||||
|
||||
API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
@ -22,6 +29,15 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Negodata Api Server", lifespan=lifespan)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[web_server_config.client_url],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축.
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
@ -42,3 +58,8 @@ async def healthz():
|
||||
|
||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||
app.include_router(router.v1.auth.account.router)
|
||||
app.include_router(router.v1.item.item.router)
|
||||
app.include_router(router.v1.supplier.supplier.router)
|
||||
app.include_router(router.v1.quotation.quotation.router)
|
||||
app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
||||
app.include_router(router.v1.enums.enums.router)
|
||||
|
||||
@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
|
||||
from services.auth_service import AuthService
|
||||
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_RefreshToken
|
||||
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
@ -14,12 +14,14 @@ router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"descripti
|
||||
|
||||
@router.post(path="/login", response_model=Res_Login, summary="로그인", description="id/pw 로 로그인하고 JWT 토큰을 발급한다.")
|
||||
async def login(request: Request, req: Req_Login, service: AuthService = Depends()):
|
||||
return RemoveNoneResponse(await service.attempt_login(req.id, req.pw, request.client.host))
|
||||
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(request: Request, req: Req_CreateAccount, service: AuthService = Depends()):
|
||||
return RemoveNoneResponse(await service.create_account(req.id, req.pw, req.nickname, request.client.host))
|
||||
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(
|
||||
@ -35,8 +37,9 @@ async def refresh_token(service: AuthService = Depends(), credentials: HTTPAutho
|
||||
|
||||
@router.get(
|
||||
path="/me",
|
||||
summary="내 정보 (보호된 엔드포인트 예시)",
|
||||
description="유효한 access 토큰이 있어야 호출 가능. 토큰 검증 결과 UserInfo 를 주입받는다.",
|
||||
response_model=Res_Me,
|
||||
summary="내 정보",
|
||||
description="유효한 access 토큰이 있어야 호출 가능. 토큰의 유저+회사 정보를 반환한다.",
|
||||
)
|
||||
async def me(user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return {"uid": user_info.uid, "id": user_info.id, "nickname": user_info.nickname}
|
||||
async def me(service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_me(user_info))
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common.enums import UserRole
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -10,25 +13,44 @@ class AuthProtocol(WebPacketProtocol):
|
||||
|
||||
class Req_Login(AuthProtocol):
|
||||
id: str = ""
|
||||
pw: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
class Res_Login(Res_WebPacketProtocol):
|
||||
uid: int = Field(0, description="user uid", json_schema_extra={"format": "int64"})
|
||||
nickname: str = ""
|
||||
access_token: str = ""
|
||||
refresh_token: str = ""
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class Req_CreateAccount(AuthProtocol):
|
||||
id: str = ""
|
||||
pw: str = ""
|
||||
nickname: str = ""
|
||||
password: str = ""
|
||||
company_id: str = ""
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
contact_number: str = ""
|
||||
role: int = UserRole.USER.value
|
||||
|
||||
|
||||
class Res_CreateAccount(Res_WebPacketProtocol):
|
||||
uid: int = Field(0, description="생성된 user uid", json_schema_extra={"format": "int64"})
|
||||
user_id: str = ""
|
||||
|
||||
|
||||
class Res_RefreshToken(Res_WebPacketProtocol):
|
||||
access_token: str = ""
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class CompanyData(WebPacketProtocol):
|
||||
company_id: str = ""
|
||||
name: str = ""
|
||||
|
||||
|
||||
class Res_Me(Res_WebPacketProtocol):
|
||||
user_id: str = ""
|
||||
id: str = ""
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
contact_number: Optional[str] = None
|
||||
role: int = UserRole.USER.value
|
||||
company: Optional[CompanyData] = Field(default=None)
|
||||
|
||||
21
negodata/backend/router/v1/enums/enums.py
Normal file
21
negodata/backend/router/v1/enums/enums.py
Normal file
@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from common.enums import DOMAIN_ENUMS, ENUM_LABELS
|
||||
from router.v1.validator.dependencies import RemoveNoneResponse
|
||||
from .protocol import EnumOption, Res_Enums
|
||||
|
||||
# 도메인 코드 enum 메타데이터(공용). 프론트가 페이지 진입 시 드롭다운을 이걸로 채운다.
|
||||
router = APIRouter(prefix="/v1", tags=["Enums"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(path="/enums", response_model=Res_Enums, summary="도메인 코드 enum 전체")
|
||||
async def list_enums():
|
||||
res = Res_Enums()
|
||||
res.enums = {
|
||||
key: [
|
||||
EnumOption(value=member.value, name=member.name, label=ENUM_LABELS.get(member, member.name))
|
||||
for member in enum_cls
|
||||
]
|
||||
for key, enum_cls in DOMAIN_ENUMS.items()
|
||||
}
|
||||
return RemoveNoneResponse(res)
|
||||
15
negodata/backend/router/v1/enums/protocol.py
Normal file
15
negodata/backend/router/v1/enums/protocol.py
Normal file
@ -0,0 +1,15 @@
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
class EnumsProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class EnumOption(WebPacketProtocol):
|
||||
value: int
|
||||
name: str
|
||||
label: str
|
||||
|
||||
|
||||
class Res_Enums(Res_WebPacketProtocol):
|
||||
enums: dict[str, list[EnumOption]] = {}
|
||||
84
negodata/backend/router/v1/item/item.py
Normal file
84
negodata/backend/router/v1/item/item.py
Normal file
@ -0,0 +1,84 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.item_service import ItemService
|
||||
from .protocol import (
|
||||
Req_CreateItem,
|
||||
Req_UpdateItem,
|
||||
Res_DeleteItem,
|
||||
Res_ExcelUpload,
|
||||
Res_Item,
|
||||
Res_ItemList,
|
||||
Res_LowestPriceResult,
|
||||
Res_LowestPriceTrigger,
|
||||
)
|
||||
|
||||
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 company_id 로 스코프.
|
||||
router = APIRouter(prefix="/v1/item", tags=["Item"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(path="/list", response_model=Res_ItemList, summary="상품 목록")
|
||||
async def list_items(
|
||||
service: ItemService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
search: str | None = Query(None, description="상품명/상품코드 검색"),
|
||||
category: str | None = Query(None, description="카테고리 필터"),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
return RemoveNoneResponse(await service.list_items(user_info.company_id, search, category, page, size))
|
||||
|
||||
|
||||
@router.post(path="/create", response_model=Res_Item, summary="상품 등록")
|
||||
async def create_item(req: Req_CreateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(
|
||||
await service.create_item(user_info.company_id, user_info.user_id, req.model_dump(exclude_unset=True))
|
||||
)
|
||||
|
||||
|
||||
@router.post(path="/upload-excel", response_model=Res_ExcelUpload, summary="엑셀 일괄 등록(스텁)")
|
||||
async def upload_items_excel(
|
||||
service: ItemService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
return RemoveNoneResponse(
|
||||
Res_ExcelUpload(received_filename=file.filename, status="not_implemented", message="엑셀 일괄 등록은 추후 구현")
|
||||
)
|
||||
|
||||
|
||||
@router.get(path="/{item_id}", response_model=Res_Item, summary="상품 조회")
|
||||
async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_item(user_info.company_id, str(item_id)))
|
||||
|
||||
|
||||
@router.patch(path="/update/{item_id}", response_model=Res_Item, summary="상품 수정")
|
||||
async def update_item(
|
||||
item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req.model_dump(exclude_unset=True)))
|
||||
|
||||
|
||||
@router.delete(path="/delete/{item_id}", response_model=Res_DeleteItem, summary="상품 삭제")
|
||||
async def delete_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id)))
|
||||
|
||||
|
||||
@router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)")
|
||||
async def trigger_lowest_price(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
# 존재/소유 확인만 (없으면 result 에 ITEM_NOT_FOUND)
|
||||
got = await service.get_item(user_info.company_id, str(item_id))
|
||||
if got.item is None:
|
||||
return RemoveNoneResponse(got)
|
||||
return RemoveNoneResponse(Res_LowestPriceTrigger(item_id=str(item_id), status="queued", message="최저가 수집 요청됨(스텁)"))
|
||||
|
||||
|
||||
@router.get(path="/{item_id}/lowest-price", response_model=Res_LowestPriceResult, summary="최저가 수집 결과(스텁)")
|
||||
async def get_lowest_price(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
got = await service.get_item(user_info.company_id, str(item_id))
|
||||
if got.item is None:
|
||||
return RemoveNoneResponse(got)
|
||||
return RemoveNoneResponse(Res_LowestPriceResult(item_id=str(item_id), results=[], message="최저가 수집 결과 없음(스텁)"))
|
||||
111
negodata/backend/router/v1/item/protocol.py
Normal file
111
negodata/backend/router/v1/item/protocol.py
Normal file
@ -0,0 +1,111 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
class ItemProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Req_CreateItem(ItemProtocol):
|
||||
name: str = ""
|
||||
code: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_type: int = 1
|
||||
image_url: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
spec: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
made_in: Optional[str] = None
|
||||
price: Optional[int] = None
|
||||
internet_lowest_price_yn: bool = False
|
||||
moq: Optional[str] = None
|
||||
lead_time: Optional[int] = None
|
||||
quantity_unit: Optional[str] = None
|
||||
delivery_type: Optional[int] = None
|
||||
vat_yn: Optional[bool] = None
|
||||
delivery_fee_yn: Optional[bool] = None
|
||||
|
||||
|
||||
class Req_UpdateItem(ItemProtocol):
|
||||
name: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_type: Optional[int] = None
|
||||
image_url: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
spec: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
made_in: Optional[str] = None
|
||||
price: Optional[int] = None
|
||||
internet_lowest_price_yn: Optional[bool] = None
|
||||
moq: Optional[str] = None
|
||||
lead_time: Optional[int] = None
|
||||
quantity_unit: Optional[str] = None
|
||||
delivery_type: Optional[int] = None
|
||||
vat_yn: Optional[bool] = None
|
||||
delivery_fee_yn: Optional[bool] = None
|
||||
|
||||
|
||||
class ItemData(WebPacketProtocol):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
item_id: uuid.UUID
|
||||
company_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
name: str
|
||||
code: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_type: int = 1
|
||||
image_url: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
spec: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
made_in: Optional[str] = None
|
||||
price: Optional[int] = None
|
||||
internet_lowest_price_yn: bool = False
|
||||
moq: Optional[str] = None
|
||||
lead_time: Optional[int] = None
|
||||
quantity_unit: Optional[str] = None
|
||||
delivery_type: Optional[int] = None
|
||||
vat_yn: Optional[bool] = None
|
||||
delivery_fee_yn: Optional[bool] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class Res_Item(Res_WebPacketProtocol):
|
||||
item: Optional[ItemData] = None
|
||||
|
||||
|
||||
class Res_ItemList(Res_WebPacketProtocol):
|
||||
items: list[ItemData] = []
|
||||
total: int = 0
|
||||
page: int = 0
|
||||
size: int = 0
|
||||
|
||||
|
||||
class Res_DeleteItem(Res_WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Res_ExcelUpload(Res_WebPacketProtocol):
|
||||
received_filename: Optional[str] = None
|
||||
status: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
class Res_LowestPriceTrigger(Res_WebPacketProtocol):
|
||||
item_id: str = ""
|
||||
status: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
class Res_LowestPriceResult(Res_WebPacketProtocol):
|
||||
item_id: str = ""
|
||||
results: list = []
|
||||
message: str = ""
|
||||
155
negodata/backend/router/v1/quotation/protocol.py
Normal file
155
negodata/backend/router/v1/quotation/protocol.py
Normal file
@ -0,0 +1,155 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
class QuotationProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Req_CreateQuotation(QuotationProtocol):
|
||||
qt_setting_id: uuid.UUID
|
||||
version_id: uuid.UUID
|
||||
name: str = ""
|
||||
number: str = ""
|
||||
type: int = 0
|
||||
status: int = 0
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
round: int = 1
|
||||
manager_name: Optional[str] = None
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
|
||||
|
||||
class QuotationData(WebPacketProtocol):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
qt_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
qt_setting_id: uuid.UUID
|
||||
version_id: uuid.UUID
|
||||
name: str
|
||||
number: str
|
||||
type: int
|
||||
round: int = 1
|
||||
status: int
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
manager_name: Optional[str] = None
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
iteration: int = 0
|
||||
preferred_sp_yn: Optional[bool] = None
|
||||
preferred_sp_id: Optional[uuid.UUID] = None
|
||||
preferred_sp_name: Optional[str] = None
|
||||
equal_bid_yn: Optional[bool] = None
|
||||
equal_bid_data: Optional[Any] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class Res_Quotation(Res_WebPacketProtocol):
|
||||
quotation: Optional[QuotationData] = None
|
||||
|
||||
|
||||
class Res_QuotationList(Res_WebPacketProtocol):
|
||||
quotations: list[QuotationData] = []
|
||||
total: int = 0
|
||||
page: int = 0
|
||||
size: int = 0
|
||||
|
||||
|
||||
class Res_DeleteQuotation(Res_WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class AsyncJob(WebPacketProtocol):
|
||||
status: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
class Res_CreateQuotation(Res_WebPacketProtocol):
|
||||
quotation: Optional[QuotationData] = None
|
||||
async_job: Optional[AsyncJob] = None
|
||||
|
||||
|
||||
class Res_QuotationStatus(Res_WebPacketProtocol):
|
||||
qt_id: Optional[uuid.UUID] = None
|
||||
job_status: int = 0
|
||||
message: str = ""
|
||||
|
||||
|
||||
class SessionData(WebPacketProtocol):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
session_id: uuid.UUID
|
||||
qt_id: uuid.UUID
|
||||
supplier_id: uuid.UUID
|
||||
item_id: uuid.UUID
|
||||
qt_number: str
|
||||
qt_round: int
|
||||
qt_type: int
|
||||
target_price: int
|
||||
status: int
|
||||
bid_price: Optional[int] = None
|
||||
bid_at: Optional[datetime] = None
|
||||
end_time: datetime
|
||||
reject_reason: Optional[str] = None
|
||||
reject_price: Optional[int] = None
|
||||
reject_delivery_type: Optional[int] = None
|
||||
|
||||
|
||||
class Res_QuotationSessions(Res_WebPacketProtocol):
|
||||
qt_id: Optional[uuid.UUID] = None
|
||||
sessions: list[SessionData] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ChatMessageData(WebPacketProtocol):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
chat_id: uuid.UUID
|
||||
session_id: uuid.UUID
|
||||
card_id: Optional[uuid.UUID] = None
|
||||
index: int
|
||||
sender: int
|
||||
target_price: int
|
||||
card_used_yn: Optional[bool] = None
|
||||
indicator_value: Optional[float] = None
|
||||
card_type: Optional[int] = None
|
||||
|
||||
|
||||
class Res_SessionChat(Res_WebPacketProtocol):
|
||||
session_id: Optional[uuid.UUID] = None
|
||||
messages: list[ChatMessageData] = []
|
||||
|
||||
|
||||
class Res_QuotationResult(Res_WebPacketProtocol):
|
||||
qt_id: Optional[uuid.UUID] = None
|
||||
winner_supplier_id: Optional[uuid.UUID] = None
|
||||
winner_supplier_name: Optional[str] = None
|
||||
is_equal_bid: Optional[bool] = None
|
||||
equal_bid_data: Optional[Any] = None
|
||||
result_count: int = 0
|
||||
|
||||
|
||||
class QuotationCardData(WebPacketProtocol):
|
||||
session_card_id: uuid.UUID
|
||||
qt_id: Optional[uuid.UUID] = None
|
||||
nego_card_id: Optional[uuid.UUID] = None
|
||||
wild_card_id: Optional[uuid.UUID] = None
|
||||
type: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
|
||||
|
||||
class Res_QuotationCards(Res_WebPacketProtocol):
|
||||
qt_id: Optional[uuid.UUID] = None
|
||||
cards: list[QuotationCardData] = []
|
||||
90
negodata/backend/router/v1/quotation/quotation.py
Normal file
90
negodata/backend/router/v1/quotation/quotation.py
Normal file
@ -0,0 +1,90 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.quotation_service import QuotationService
|
||||
from .protocol import (
|
||||
Req_CreateQuotation,
|
||||
Res_CreateQuotation,
|
||||
Res_DeleteQuotation,
|
||||
Res_Quotation,
|
||||
Res_QuotationCards,
|
||||
Res_QuotationList,
|
||||
Res_QuotationResult,
|
||||
Res_QuotationSessions,
|
||||
Res_QuotationStatus,
|
||||
Res_SessionChat,
|
||||
)
|
||||
|
||||
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받는다.
|
||||
# quotations 테이블에 company_id 가 없어 회사 스코핑은 하지 않는다(토큰 검증만).
|
||||
# 라우팅 주의: 정적/하위 경로(/list, /create, /{qt_id}/status ...)를 /{qt_id} 보다 먼저 선언해야
|
||||
# /{qt_id} 가 /list 등을 가로채지 않는다.
|
||||
router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
# ----- 실제 엔드포인트 (DB) -----
|
||||
@router.get(path="/list", response_model=Res_QuotationList, summary="견적 목록")
|
||||
async def list_quotations(
|
||||
service: QuotationService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
status: str | None = Query(None, description="상태 필터(정확히 일치)"),
|
||||
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
|
||||
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
|
||||
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
return RemoveNoneResponse(await service.list_quotations(status, type, start_from, start_to, page, size))
|
||||
|
||||
|
||||
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")
|
||||
async def create_quotation(
|
||||
req: Req_CreateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.create_quotation(user_info.user_id, req.model_dump(exclude_unset=True)))
|
||||
|
||||
|
||||
@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)))
|
||||
|
||||
|
||||
# ----- 견적 상세 (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)))
|
||||
|
||||
|
||||
@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)))
|
||||
|
||||
|
||||
@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)))
|
||||
|
||||
|
||||
@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)))
|
||||
|
||||
|
||||
@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)))
|
||||
|
||||
|
||||
@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)))
|
||||
|
||||
|
||||
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----
|
||||
@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)))
|
||||
48
negodata/backend/router/v1/quotation_setting/protocol.py
Normal file
48
negodata/backend/router/v1/quotation_setting/protocol.py
Normal file
@ -0,0 +1,48 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
class QuotationSettingProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Req_CreateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: float
|
||||
anchoring_value: float = 0.01
|
||||
card_count: int = 3
|
||||
|
||||
|
||||
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: Optional[float] = None
|
||||
anchoring_value: Optional[float] = None
|
||||
card_count: Optional[int] = None
|
||||
|
||||
|
||||
class QuotationSettingData(WebPacketProtocol):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
qt_setting_id: uuid.UUID
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
target_margin_rate: float
|
||||
anchoring_value: float
|
||||
card_count: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class Res_QuotationSetting(Res_WebPacketProtocol):
|
||||
setting: Optional[QuotationSettingData] = None
|
||||
|
||||
|
||||
class Res_QuotationSettingList(Res_WebPacketProtocol):
|
||||
settings: list[QuotationSettingData] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class Res_DeleteQuotationSetting(Res_WebPacketProtocol):
|
||||
pass
|
||||
@ -0,0 +1,52 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.quotation_setting_service import QuotationSettingService
|
||||
from .protocol import (
|
||||
Req_CreateQuotationSetting,
|
||||
Req_UpdateQuotationSetting,
|
||||
Res_DeleteQuotationSetting,
|
||||
Res_QuotationSetting,
|
||||
Res_QuotationSettingList,
|
||||
)
|
||||
|
||||
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 user_id 로 스코프.
|
||||
router = APIRouter(
|
||||
prefix="/v1/quotation-setting", tags=["QuotationSetting"], responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
@router.get(path="/list", response_model=Res_QuotationSettingList, summary="견적 설정 목록")
|
||||
async def list_settings(service: QuotationSettingService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.list_settings(user_info.user_id))
|
||||
|
||||
|
||||
@router.post(path="/create", response_model=Res_QuotationSetting, summary="견적 설정 등록")
|
||||
async def create_setting(
|
||||
req: Req_CreateQuotationSetting,
|
||||
service: QuotationSettingService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
):
|
||||
return RemoveNoneResponse(await service.create_setting(user_info.user_id, req.model_dump(exclude_unset=True)))
|
||||
|
||||
|
||||
@router.patch(path="/update/{qt_setting_id}", response_model=Res_QuotationSetting, summary="견적 설정 수정")
|
||||
async def update_setting(
|
||||
qt_setting_id: UUID,
|
||||
req: Req_UpdateQuotationSetting,
|
||||
service: QuotationSettingService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
):
|
||||
return RemoveNoneResponse(
|
||||
await service.update_setting(user_info.user_id, str(qt_setting_id), req.model_dump(exclude_unset=True))
|
||||
)
|
||||
|
||||
|
||||
@router.delete(path="/delete/{qt_setting_id}", response_model=Res_DeleteQuotationSetting, summary="견적 설정 삭제")
|
||||
async def delete_setting(
|
||||
qt_setting_id: UUID, service: QuotationSettingService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.delete_setting(user_info.user_id, str(qt_setting_id)))
|
||||
66
negodata/backend/router/v1/supplier/protocol.py
Normal file
66
negodata/backend/router/v1/supplier/protocol.py
Normal file
@ -0,0 +1,66 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
class SupplierProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Req_CreateSupplier(SupplierProtocol):
|
||||
name: str = ""
|
||||
code: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
|
||||
|
||||
class Req_UpdateSupplier(SupplierProtocol):
|
||||
name: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
|
||||
|
||||
class SupplierData(WebPacketProtocol):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
supplier_id: uuid.UUID
|
||||
company_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
name: str
|
||||
code: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class Res_Supplier(Res_WebPacketProtocol):
|
||||
supplier: Optional[SupplierData] = None
|
||||
|
||||
|
||||
class Res_SupplierList(Res_WebPacketProtocol):
|
||||
suppliers: list[SupplierData] = []
|
||||
total: int = 0
|
||||
page: int = 0
|
||||
size: int = 0
|
||||
|
||||
|
||||
class Res_DeleteSupplier(Res_WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class Res_ExcelUpload(Res_WebPacketProtocol):
|
||||
received_filename: Optional[str] = None
|
||||
status: str = ""
|
||||
message: str = ""
|
||||
68
negodata/backend/router/v1/supplier/supplier.py
Normal file
68
negodata/backend/router/v1/supplier/supplier.py
Normal file
@ -0,0 +1,68 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.supplier_service import SupplierService
|
||||
from .protocol import (
|
||||
Req_CreateSupplier,
|
||||
Req_UpdateSupplier,
|
||||
Res_DeleteSupplier,
|
||||
Res_ExcelUpload,
|
||||
Res_Supplier,
|
||||
Res_SupplierList,
|
||||
)
|
||||
|
||||
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 company_id 로 스코프.
|
||||
router = APIRouter(prefix="/v1/supplier", tags=["Supplier"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(path="/list", response_model=Res_SupplierList, summary="협력사 목록")
|
||||
async def list_suppliers(
|
||||
service: SupplierService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
search: str | None = Query(None, description="협력사명/코드/담당자명 검색"),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, page, size))
|
||||
|
||||
|
||||
@router.post(path="/create", response_model=Res_Supplier, summary="협력사 등록")
|
||||
async def create_supplier(
|
||||
req: Req_CreateSupplier, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(
|
||||
await service.create_supplier(user_info.company_id, user_info.user_id, req.model_dump(exclude_unset=True))
|
||||
)
|
||||
|
||||
|
||||
@router.post(path="/upload-excel", response_model=Res_ExcelUpload, summary="협력사 엑셀 일괄 등록(스텁)")
|
||||
async def upload_suppliers_excel(
|
||||
service: SupplierService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
return RemoveNoneResponse(
|
||||
Res_ExcelUpload(received_filename=file.filename, status="not_implemented", message="엑셀 일괄 등록은 추후 구현")
|
||||
)
|
||||
|
||||
|
||||
@router.get(path="/{supplier_id}", response_model=Res_Supplier, summary="협력사 조회")
|
||||
async def get_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_supplier(user_info.company_id, str(supplier_id)))
|
||||
|
||||
|
||||
@router.patch(path="/update/{supplier_id}", response_model=Res_Supplier, summary="협력사 수정")
|
||||
async def update_supplier(
|
||||
supplier_id: UUID, req: Req_UpdateSupplier, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(
|
||||
await service.update_supplier(user_info.company_id, str(supplier_id), req.model_dump(exclude_unset=True))
|
||||
)
|
||||
|
||||
|
||||
@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제")
|
||||
async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id)))
|
||||
@ -110,4 +110,6 @@ def RemoveNoneValues(obj: Any) -> Any:
|
||||
|
||||
|
||||
def RemoveNoneResponse(obj) -> ORJSONResponse:
|
||||
return ORJSONResponse(content=RemoveNoneValues(obj.model_dump()))
|
||||
# mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서
|
||||
# JSON 안전한 문자열로 변환한다. (python 모드면 orjson 이 asyncpg.UUID 를 직렬화 못 함)
|
||||
return ORJSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json")))
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import tbl_account
|
||||
from common.enums import DBWRType, ErrorType
|
||||
from common.database.model.models import users
|
||||
from common.enums import DBWRType, ErrorType, UserStatus, UserRole
|
||||
from common.logger import LOG
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.user_crud import IUserCRUD, UserCRUD
|
||||
from router.v1.auth.protocol import Res_CreateAccount, Res_Login, Res_RefreshToken
|
||||
from router.v1.auth.protocol import CompanyData, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken
|
||||
from router.v1.validator.dependencies import (
|
||||
CreateAccessToken,
|
||||
CreateRefreshToken,
|
||||
@ -29,59 +31,68 @@ class AuthService:
|
||||
def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)):
|
||||
self.user_crud = user_crud
|
||||
|
||||
async def attempt_login(self, id: str, pw: str, connect_ip: str) -> Res_Login:
|
||||
LOG.i(f"LOGIN : {id=}")
|
||||
@staticmethod
|
||||
def _user_info(user: users) -> UserInfo:
|
||||
# uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다.
|
||||
return UserInfo(
|
||||
user_id=str(user.user_id),
|
||||
id=user.id,
|
||||
company_id=str(user.company_id),
|
||||
)
|
||||
|
||||
async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login:
|
||||
LOG.i(f"LOGIN : id={login_id}")
|
||||
res = Res_Login()
|
||||
|
||||
# 1) 계정 조회 (Read DB)
|
||||
err_type, account = await DB_SESSION_MNG.execute_lambda(
|
||||
tbl_account.DBType(),
|
||||
err_type, user = await DB_SESSION_MNG.execute_lambda(
|
||||
users.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.get_account_by_id(s, id),
|
||||
lambda s: self.user_crud.get_user_by_login_id(s, login_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
# 계정 없음/조회 실패 모두 로그인 실패로 일반화
|
||||
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
|
||||
return res
|
||||
account: tbl_account
|
||||
user: users
|
||||
|
||||
# 2) 비밀번호 검증
|
||||
if not await VerifyPW(pw, account.pw):
|
||||
if not await VerifyPW(password, user.password):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
|
||||
return res
|
||||
|
||||
# 3) 차단 여부
|
||||
if account.is_blocked:
|
||||
# 3) 상태 확인 (활성 아니면 차단)
|
||||
if user.status != UserStatus.ACTIVE.value:
|
||||
res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER)
|
||||
return res
|
||||
|
||||
# 4) 토큰 발급
|
||||
user_info = UserInfo(uid=account.uid, id=account.id, nickname=account.nickname)
|
||||
user_info = self._user_info(user)
|
||||
res.access_token = CreateAccessToken(user_info)
|
||||
res.refresh_token = CreateRefreshToken(user_info)
|
||||
|
||||
# 5) 마지막 로그인 시간 갱신 (Write DB, 트랜잭션)
|
||||
# 5) 마지막 접속 시간 갱신 (Write DB, 트랜잭션)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[tbl_account.DBType()],
|
||||
[lambda s: self.user_crud.update_last_login(s, account.uid)],
|
||||
[users.DBType()],
|
||||
[lambda s: self.user_crud.update_last_accessed(s, user.user_id)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
res.uid = account.uid
|
||||
res.nickname = account.nickname
|
||||
return res
|
||||
|
||||
async def create_account(self, id: str, pw: str, nickname: str, connect_ip: str) -> Res_CreateAccount:
|
||||
LOG.i(f"CREATE : {id=}, {nickname=}")
|
||||
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(
|
||||
tbl_account.DBType(),
|
||||
users.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.is_account(s, id),
|
||||
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)
|
||||
@ -91,10 +102,18 @@ class AuthService:
|
||||
return res
|
||||
|
||||
# 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장)
|
||||
account = tbl_account(id=id, pw=await GetHashedPW(pw), nickname=nickname or id)
|
||||
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(
|
||||
[tbl_account.DBType()],
|
||||
[lambda s: self.user_crud.add_account(s, account)],
|
||||
[users.DBType()],
|
||||
[lambda s: self.user_crud.add_user(s, user)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
# 사전 검사와 INSERT 사이의 경쟁 조건에서 unique 위반이 나면 동일 코드로 매핑.
|
||||
@ -104,7 +123,40 @@ class AuthService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
res.uid = account.uid
|
||||
res.user_id = str(user.user_id)
|
||||
return res
|
||||
|
||||
async def get_me(self, user_info: UserInfo) -> Res_Me:
|
||||
res = Res_Me()
|
||||
|
||||
# 1) 유저 조회
|
||||
err_type, user = await DB_SESSION_MNG.execute_lambda(
|
||||
users.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.get_user_by_login_id(s, user_info.id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
|
||||
return res
|
||||
user: users
|
||||
|
||||
# 2) 소속사 조회 (없어도 치명적 아님)
|
||||
company = None
|
||||
c_err, company_row = await DB_SESSION_MNG.execute_lambda(
|
||||
users.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.get_company(s, user.company_id),
|
||||
)
|
||||
if c_err == ErrorType.SUCCESS and company_row is not None:
|
||||
company = CompanyData(company_id=str(company_row.company_id), name=company_row.name)
|
||||
|
||||
res.user_id = str(user.user_id)
|
||||
res.id = user.id
|
||||
res.name = user.name
|
||||
res.email = user.email
|
||||
res.contact_number = user.contact_number
|
||||
res.role = user.role
|
||||
res.company = company
|
||||
return res
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
|
||||
|
||||
109
negodata/backend/services/item_service.py
Normal file
109
negodata/backend/services/item_service.py
Normal file
@ -0,0 +1,109 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items
|
||||
from common.enums import DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from crud.item_crud import IItemCRUD, ItemCRUD
|
||||
from router.v1.item.protocol import ItemData, Res_DeleteItem, Res_Item, Res_ItemList
|
||||
|
||||
|
||||
class ItemService:
|
||||
"""상품 비즈니스 로직. company_id 로 소유권을 확인한다(멀티테넌트)."""
|
||||
|
||||
def __init__(self, item_crud: IItemCRUD = Depends(ItemCRUD)):
|
||||
self.item_crud = item_crud
|
||||
|
||||
async def _fetch_owned(self, company_uuid: uuid.UUID, item_id: uuid.UUID):
|
||||
"""item 조회 + 소유권 확인. (ErrorType, item|None) 반환."""
|
||||
err_type, item = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.item_crud.get_by_id(s, item_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or item is None:
|
||||
return ErrorType.ITEM_NOT_FOUND, None
|
||||
if item.company_id != company_uuid:
|
||||
return ErrorType.ITEM_NOT_FOUND, None
|
||||
return ErrorType.SUCCESS, item
|
||||
|
||||
async def list_items(self, company_id: str, search, category, page: int, size: int) -> Res_ItemList:
|
||||
res = Res_ItemList(page=page, size=size)
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
skip = (page - 1) * size
|
||||
|
||||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.item_crud.search(s, company_uuid, search, category, skip, size),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.items = [ItemData.model_validate(r) for r in rows]
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
async def get_item(self, company_id: str, item_id: str) -> Res_Item:
|
||||
res = Res_Item()
|
||||
err_type, item = await self._fetch_owned(uuid.UUID(company_id), uuid.UUID(item_id))
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.item = ItemData.model_validate(item)
|
||||
return res
|
||||
|
||||
async def create_item(self, company_id: str, user_id: str, data: dict) -> Res_Item:
|
||||
res = Res_Item()
|
||||
item = items(**data, company_id=uuid.UUID(company_id), user_id=uuid.UUID(user_id))
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[items.DBType()],
|
||||
[lambda s: self.item_crud.add_item(s, item)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다.
|
||||
return await self.get_item(company_id, str(item.item_id))
|
||||
|
||||
async def update_item(self, company_id: str, item_id: str, data: dict) -> Res_Item:
|
||||
res = Res_Item()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
item_uuid = uuid.UUID(item_id)
|
||||
|
||||
# 소유권 확인
|
||||
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[items.DBType()],
|
||||
[lambda s: self.item_crud.update_item(s, item_uuid, data)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 갱신 후 재조회
|
||||
return await self.get_item(company_id, item_id)
|
||||
|
||||
async def delete_item(self, company_id: str, item_id: str) -> Res_DeleteItem:
|
||||
res = Res_DeleteItem()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
item_uuid = uuid.UUID(item_id)
|
||||
|
||||
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[items.DBType()],
|
||||
[lambda s: self.item_crud.soft_delete(s, item_uuid)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
184
negodata/backend/services/quotation_service.py
Normal file
184
negodata/backend/services/quotation_service.py
Normal file
@ -0,0 +1,184 @@
|
||||
import uuid
|
||||
|
||||
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 crud.quotation_crud import IQuotationCRUD, QuotationCRUD
|
||||
from router.v1.quotation.protocol import (
|
||||
AsyncJob,
|
||||
QuotationData,
|
||||
Res_CreateQuotation,
|
||||
Res_DeleteQuotation,
|
||||
Res_Quotation,
|
||||
Res_QuotationCards,
|
||||
Res_QuotationList,
|
||||
Res_QuotationResult,
|
||||
Res_QuotationSessions,
|
||||
Res_QuotationStatus,
|
||||
Res_SessionChat,
|
||||
)
|
||||
|
||||
|
||||
class QuotationService:
|
||||
"""견적 비즈니스 로직.
|
||||
|
||||
quotations 테이블에는 company_id 가 없어 회사 스코핑은 하지 않는다(토큰 검증만).
|
||||
user_id 는 생성 시 소유자로만 기록한다(조회/변경 시 소유권 필터 없음).
|
||||
"""
|
||||
|
||||
def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
|
||||
self.quotation_crud = quotation_crud
|
||||
|
||||
async def _fetch(self, qt_id: uuid.UUID):
|
||||
"""견적 단건 조회. (ErrorType, quotation|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),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||||
return ErrorType.QUOTATION_NOT_FOUND, None
|
||||
return ErrorType.SUCCESS, quotation
|
||||
|
||||
async def list_quotations(self, status, type_, start_from, start_to, page: int, size: int) -> Res_QuotationList:
|
||||
res = Res_QuotationList(page=page, size=size)
|
||||
skip = (page - 1) * size
|
||||
|
||||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.search(s, status, type_, start_from, start_to, skip, size),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
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:
|
||||
res = Res_Quotation()
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.quotation = QuotationData.model_validate(quotation)
|
||||
return res
|
||||
|
||||
async def create_quotation(self, user_id: str, data: dict) -> Res_CreateQuotation:
|
||||
res = Res_CreateQuotation()
|
||||
quotation = quotations(**data, user_id=uuid.UUID(user_id))
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotations.DBType()],
|
||||
[lambda s: self.quotation_crud.add_quotation(s, quotation)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 서버 기본값(created_at/updated_at) 로드 위해 재조회 (응답 shape 은 Res_CreateQuotation 유지).
|
||||
f_err, fresh = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_by_id(s, quotation.qt_id),
|
||||
)
|
||||
res.quotation = QuotationData.model_validate(fresh if f_err == ErrorType.SUCCESS and fresh is not None else quotation)
|
||||
# 네고시움 백엔드 비동기 요청은 스텁이므로 row 만 생성한다.
|
||||
res.async_job = AsyncJob(status="submitted", message="견적 생성 작업 요청됨(스텁)")
|
||||
return res
|
||||
|
||||
async def stop_quotation(self, qt_id: str) -> Res_Quotation:
|
||||
res = Res_Quotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
# 존재 확인
|
||||
err_type, _ = await self._fetch(qt_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 상태를 '견적마감'으로 변경(실제 DB 업데이트)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotations.DBType()],
|
||||
[lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": "견적마감"})],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 갱신 후 재조회
|
||||
return await self.get_quotation(qt_id)
|
||||
|
||||
async def delete_quotation(self, qt_id: str) -> Res_DeleteQuotation:
|
||||
res = Res_DeleteQuotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
err_type, _ = await self._fetch(qt_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotations.DBType()],
|
||||
[lambda s: self.quotation_crud.soft_delete(s, qt_uuid)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
async def get_status(self, qt_id: str) -> Res_QuotationStatus:
|
||||
res = Res_QuotationStatus()
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.qt_id = quotation.qt_id
|
||||
res.job_status = quotation.status
|
||||
res.message = "ok"
|
||||
return res
|
||||
|
||||
async def get_result(self, qt_id: str) -> Res_QuotationResult:
|
||||
res = Res_QuotationResult()
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 낙찰 결과는 quotations 컬럼에서 직접 노출. results 테이블 미존재로 result_count 는 0.
|
||||
res.qt_id = quotation.qt_id
|
||||
res.winner_supplier_id = quotation.preferred_sp_id
|
||||
res.winner_supplier_name = quotation.preferred_sp_name
|
||||
res.is_equal_bid = quotation.equal_bid_yn
|
||||
res.equal_bid_data = quotation.equal_bid_data
|
||||
res.result_count = 0
|
||||
return res
|
||||
|
||||
async def list_sessions(self, qt_id: str) -> Res_QuotationSessions:
|
||||
# sessions 모델 미존재 — 존재 검증 후 빈 목록 반환(스텁).
|
||||
res = Res_QuotationSessions()
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.qt_id = quotation.qt_id
|
||||
res.sessions = []
|
||||
res.total = 0
|
||||
return res
|
||||
|
||||
async def list_chats(self, session_id: str) -> Res_SessionChat:
|
||||
# chats 모델 미존재 — 빈 목록 반환(스텁).
|
||||
res = Res_SessionChat()
|
||||
res.session_id = uuid.UUID(session_id)
|
||||
res.messages = []
|
||||
return res
|
||||
|
||||
async def list_cards(self, qt_id: str) -> Res_QuotationCards:
|
||||
# quotation_cards 모델 미존재 — 존재 검증 후 빈 목록 반환(스텁).
|
||||
res = Res_QuotationCards()
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.qt_id = quotation.qt_id
|
||||
res.cards = []
|
||||
return res
|
||||
112
negodata/backend/services/quotation_setting_service.py
Normal file
112
negodata/backend/services/quotation_setting_service.py
Normal file
@ -0,0 +1,112 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotation_settings
|
||||
from common.enums import DBWRType, ErrorType
|
||||
from crud.quotation_setting_crud import IQuotationSettingCRUD, QuotationSettingCRUD
|
||||
from router.v1.quotation_setting.protocol import (
|
||||
QuotationSettingData,
|
||||
Res_DeleteQuotationSetting,
|
||||
Res_QuotationSetting,
|
||||
Res_QuotationSettingList,
|
||||
)
|
||||
|
||||
|
||||
class QuotationSettingService:
|
||||
"""견적 설정 비즈니스 로직. user_id 로 소유권을 확인한다(유저별 설정)."""
|
||||
|
||||
def __init__(self, qs_crud: IQuotationSettingCRUD = Depends(QuotationSettingCRUD)):
|
||||
self.qs_crud = qs_crud
|
||||
|
||||
async def _fetch_owned(self, user_uuid: uuid.UUID, qt_setting_id: uuid.UUID):
|
||||
"""설정 조회 + 소유권 확인. (ErrorType, setting|None) 반환."""
|
||||
err_type, setting = await DB_SESSION_MNG.execute_lambda(
|
||||
quotation_settings.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.qs_crud.get_by_id(s, qt_setting_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or setting is None:
|
||||
return ErrorType.QUOTATION_SETTING_NOT_FOUND, None
|
||||
if setting.user_id != user_uuid:
|
||||
return ErrorType.QUOTATION_SETTING_NOT_FOUND, None
|
||||
return ErrorType.SUCCESS, setting
|
||||
|
||||
async def list_settings(self, user_id: str) -> Res_QuotationSettingList:
|
||||
res = Res_QuotationSettingList()
|
||||
user_uuid = uuid.UUID(user_id)
|
||||
|
||||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||
quotation_settings.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.qs_crud.list_by_user(s, user_uuid),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.settings = [QuotationSettingData.model_validate(r) for r in rows]
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
async def get_setting(self, user_id: str, qt_setting_id: str) -> Res_QuotationSetting:
|
||||
res = Res_QuotationSetting()
|
||||
err_type, setting = await self._fetch_owned(uuid.UUID(user_id), uuid.UUID(qt_setting_id))
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.setting = QuotationSettingData.model_validate(setting)
|
||||
return res
|
||||
|
||||
async def create_setting(self, user_id: str, data: dict) -> Res_QuotationSetting:
|
||||
res = Res_QuotationSetting()
|
||||
setting = quotation_settings(**data, user_id=uuid.UUID(user_id))
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotation_settings.DBType()],
|
||||
[lambda s: self.qs_crud.add_setting(s, setting)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 서버 기본값(created_at/updated_at)은 insert 후 객체에 실리지 않으므로 재조회한다.
|
||||
return await self.get_setting(user_id, str(setting.qt_setting_id))
|
||||
|
||||
async def update_setting(self, user_id: str, qt_setting_id: str, data: dict) -> Res_QuotationSetting:
|
||||
res = Res_QuotationSetting()
|
||||
user_uuid = uuid.UUID(user_id)
|
||||
setting_uuid = uuid.UUID(qt_setting_id)
|
||||
|
||||
# 소유권 확인
|
||||
err_type, _ = await self._fetch_owned(user_uuid, setting_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotation_settings.DBType()],
|
||||
[lambda s: self.qs_crud.update_setting(s, setting_uuid, data)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 갱신 후 재조회
|
||||
return await self.get_setting(user_id, qt_setting_id)
|
||||
|
||||
async def delete_setting(self, user_id: str, qt_setting_id: str) -> Res_DeleteQuotationSetting:
|
||||
res = Res_DeleteQuotationSetting()
|
||||
user_uuid = uuid.UUID(user_id)
|
||||
setting_uuid = uuid.UUID(qt_setting_id)
|
||||
|
||||
err_type, _ = await self._fetch_owned(user_uuid, setting_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotation_settings.DBType()],
|
||||
[lambda s: self.qs_crud.soft_delete(s, setting_uuid)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
109
negodata/backend/services/supplier_service.py
Normal file
109
negodata/backend/services/supplier_service.py
Normal file
@ -0,0 +1,109 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import suppliers
|
||||
from common.enums import DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from crud.supplier_crud import ISupplierCRUD, SupplierCRUD
|
||||
from router.v1.supplier.protocol import Res_DeleteSupplier, Res_Supplier, Res_SupplierList, SupplierData
|
||||
|
||||
|
||||
class SupplierService:
|
||||
"""협력사 비즈니스 로직. company_id 로 소유권을 확인한다(멀티테넌트)."""
|
||||
|
||||
def __init__(self, supplier_crud: ISupplierCRUD = Depends(SupplierCRUD)):
|
||||
self.supplier_crud = supplier_crud
|
||||
|
||||
async def _fetch_owned(self, company_uuid: uuid.UUID, supplier_id: uuid.UUID):
|
||||
"""supplier 조회 + 소유권 확인. (ErrorType, supplier|None) 반환."""
|
||||
err_type, supplier = await DB_SESSION_MNG.execute_lambda(
|
||||
suppliers.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.supplier_crud.get_by_id(s, supplier_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or supplier is None:
|
||||
return ErrorType.SUPPLIER_NOT_FOUND, None
|
||||
if supplier.company_id != company_uuid:
|
||||
return ErrorType.SUPPLIER_NOT_FOUND, None
|
||||
return ErrorType.SUCCESS, supplier
|
||||
|
||||
async def list_suppliers(self, company_id: str, search, page: int, size: int) -> Res_SupplierList:
|
||||
res = Res_SupplierList(page=page, size=size)
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
skip = (page - 1) * size
|
||||
|
||||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||
suppliers.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.supplier_crud.search(s, company_uuid, search, skip, size),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.suppliers = [SupplierData.model_validate(r) for r in rows]
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
async def get_supplier(self, company_id: str, supplier_id: str) -> Res_Supplier:
|
||||
res = Res_Supplier()
|
||||
err_type, supplier = await self._fetch_owned(uuid.UUID(company_id), uuid.UUID(supplier_id))
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.supplier = SupplierData.model_validate(supplier)
|
||||
return res
|
||||
|
||||
async def create_supplier(self, company_id: str, user_id: str, data: dict) -> Res_Supplier:
|
||||
res = Res_Supplier()
|
||||
supplier = suppliers(**data, company_id=uuid.UUID(company_id), user_id=uuid.UUID(user_id))
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[suppliers.DBType()],
|
||||
[lambda s: self.supplier_crud.add_supplier(s, supplier)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 서버 기본값(created_at/updated_at)은 insert 후 객체에 실리지 않으므로 재조회한다.
|
||||
return await self.get_supplier(company_id, str(supplier.supplier_id))
|
||||
|
||||
async def update_supplier(self, company_id: str, supplier_id: str, data: dict) -> Res_Supplier:
|
||||
res = Res_Supplier()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
supplier_uuid = uuid.UUID(supplier_id)
|
||||
|
||||
# 소유권 확인
|
||||
err_type, _ = await self._fetch_owned(company_uuid, supplier_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[suppliers.DBType()],
|
||||
[lambda s: self.supplier_crud.update_supplier(s, supplier_uuid, data)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
# 갱신 후 재조회
|
||||
return await self.get_supplier(company_id, supplier_id)
|
||||
|
||||
async def delete_supplier(self, company_id: str, supplier_id: str) -> Res_DeleteSupplier:
|
||||
res = Res_DeleteSupplier()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
supplier_uuid = uuid.UUID(supplier_id)
|
||||
|
||||
err_type, _ = await self._fetch_owned(company_uuid, supplier_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[suppliers.DBType()],
|
||||
[lambda s: self.supplier_crud.soft_delete(s, supplier_uuid)],
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
@ -1,39 +1,48 @@
|
||||
"""auth 도메인 e2e 테스트.
|
||||
"""auth 도메인 e2e 테스트 (negodata: users/companies 기반).
|
||||
|
||||
실행 전제: docker-compose 로 PostgreSQL 이 떠 있어야 한다 (negodata_db 사용).
|
||||
실행 전제: PostgreSQL(negodata_db)이 떠 있어야 한다.
|
||||
docker compose up -d # 또는 로컬 postgres
|
||||
cd negodata/backend && python -m pytest
|
||||
계정 생성은 company_id 를 요구하므로 company_id 픽스처(conftest)가 소속사를 시드한다.
|
||||
"""
|
||||
|
||||
|
||||
async def test_create_and_login_flow(client):
|
||||
# 1) 계정 생성
|
||||
r = await client.post("/v1/auth/create", json={"id": "user1", "pw": "pw1234", "nickname": "닉네임"})
|
||||
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["uid"] > 0
|
||||
assert body["user_id"]
|
||||
|
||||
# 2) 로그인 -> 토큰 발급
|
||||
r = await client.post("/v1/auth/login", json={"id": "user1", "pw": "pw1234"})
|
||||
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"]
|
||||
assert body["nickname"] == "닉네임"
|
||||
access_token = body["access_token"]
|
||||
|
||||
# 3) 보호된 엔드포인트 호출
|
||||
# 3) 보호된 엔드포인트(/me) — 토큰의 유저 + 소속사 반환
|
||||
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == "user1"
|
||||
me = r.json()
|
||||
assert me["id"] == "user1"
|
||||
assert me["name"] == "홍길동"
|
||||
assert me["company"]["company_id"] == company_id
|
||||
|
||||
|
||||
async def test_login_with_wrong_password(client):
|
||||
await client.post("/v1/auth/create", json={"id": "user2", "pw": "correct", "nickname": "n"})
|
||||
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"},
|
||||
)
|
||||
|
||||
r = await client.post("/v1/auth/login", json={"id": "user2", "pw": "wrong"})
|
||||
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
|
||||
@ -43,15 +52,21 @@ async def test_login_with_wrong_password(client):
|
||||
|
||||
|
||||
async def test_login_nonexistent_account(client):
|
||||
r = await client.post("/v1/auth/login", json={"id": "ghost", "pw": "whatever"})
|
||||
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):
|
||||
r1 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw1234", "nickname": "n"})
|
||||
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", "pw": "pw5678", "nickname": "n2"})
|
||||
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)
|
||||
|
||||
68
negodata/backend/tests/test_features.py
Normal file
68
negodata/backend/tests/test_features.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""supplier / quotation_setting / quotation 슬라이스 런타임 스모크.
|
||||
|
||||
create(재조회로 created_at 적재) + list + get 경로를 라이브 DB 로 확인한다.
|
||||
"""
|
||||
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']}"}
|
||||
|
||||
|
||||
async def test_supplier_crud(client, company_id):
|
||||
h = await _headers(client, company_id, "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"] # 재조회 픽스: 서버 기본값 적재 확인
|
||||
sid = sup["supplier_id"]
|
||||
|
||||
r = await client.get("/v1/supplier/list", headers=h)
|
||||
assert r.json()["total"] == 1
|
||||
|
||||
r = await client.get(f"/v1/supplier/{sid}", headers=h)
|
||||
assert r.json()["supplier"]["supplier_id"] == sid
|
||||
|
||||
|
||||
async def test_quotation_setting_crud(client, company_id):
|
||||
h = await _headers(client, company_id, "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
|
||||
st = body["setting"]
|
||||
assert st["target_margin_rate"] == 0.15
|
||||
assert st["card_count"] == 3 # 기본값
|
||||
assert st["created_at"]
|
||||
|
||||
r = await client.get("/v1/quotation-setting/list", headers=h)
|
||||
assert r.json()["total"] >= 1
|
||||
|
||||
|
||||
async def test_quotation_create(client, company_id):
|
||||
h = await _headers(client, company_id, "qtuser")
|
||||
body = {
|
||||
"qt_setting_id": str(uuid.uuid4()),
|
||||
"version_id": str(uuid.uuid4()),
|
||||
"name": "견적A",
|
||||
"number": "Q-001",
|
||||
"type": "재견적",
|
||||
"status": "진행중",
|
||||
"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()
|
||||
assert res["result"]["success"] is True
|
||||
q = res["quotation"]
|
||||
assert q["name"] == "견적A"
|
||||
assert q["created_at"] # 재조회 픽스
|
||||
|
||||
r = await client.get("/v1/quotation/list", headers=h)
|
||||
assert r.json()["total"] >= 1
|
||||
85
negodata/backend/tests/test_item.py
Normal file
85
negodata/backend/tests/test_item.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""item 도메인 e2e — CRUD + company 멀티테넌트 스코프 검증.
|
||||
|
||||
실행 전제: PostgreSQL(negodata_db). docker compose up -d 후 python -m pytest.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
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()
|
||||
assert body["result"]["success"] is True
|
||||
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
|
||||
|
||||
# 수정 (부분)
|
||||
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" # 미지정 필드 유지
|
||||
|
||||
# 삭제 (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
|
||||
|
||||
|
||||
async def test_item_not_found(client, company_id):
|
||||
h = await _headers(client, company_id)
|
||||
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
|
||||
Loading…
Reference in New Issue
Block a user