[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:
Mina Choi 2026-06-17 15:58:38 +09:00
parent 7e2d5e5978
commit 1f778ab975
33 changed files with 2255 additions and 85 deletions

View File

@ -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)

View File

@ -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,
}

View File

@ -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__()

View File

@ -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):

View File

@ -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():
"""테스트 세션이 끝날 때 앱 싱글톤 엔진을 정리한다.

View 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

View 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

View 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

View 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

View File

@ -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

View File

@ -1,8 +1,11 @@
fastapi
uvicorn[standard]
sqlalchemy>=2.0
greenlet
asyncpg
python-jose[cryptography]
bcrypt
orjson
pydantic>=2.0
python-multipart
openpyxl

View File

@ -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)

View File

@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
from services.auth_service import AuthService
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_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))

View File

@ -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)

View 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)

View 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]] = {}

View 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="최저가 수집 결과 없음(스텁)"))

View 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 = ""

View 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] = []

View 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)))

View 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

View File

@ -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)))

View 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 = ""

View 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)))

View File

@ -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")))

View File

@ -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:

View 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

View 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

View 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

View 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

View File

@ -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)

View 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

View 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