From d21e7c10b05ebc04ccde27c81203768af337a1cb Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 7 Jul 2026 17:04:23 +0900 Subject: [PATCH] =?UTF-8?q?[refactor]=20=EA=B3=B5=EA=B8=89=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EA=B8=B0=EC=A4=80=EC=9D=84=20=EC=83=81=ED=92=88-?= =?UTF-8?q?=ED=98=91=EB=A0=A5=EC=82=AC=20=EB=A7=A4=ED=95=91=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/repository/nego_context_crud.py | 21 ----- .../service/negotiation_context_loader.py | 13 +-- agent/router/v1/chat/protocol.py | 2 +- agent/services/chat_service.py | 4 +- agent/tests/test_context_loader.py | 12 +-- backend/common/database/model/models.py | 1 - backend/tests/test_anchoring_chat.py | 9 +- negodata/backend/common/anchoring/reader.py | 18 ++-- .../backend/common/database/model/models.py | 4 +- negodata/backend/common/enums.py | 4 +- negodata/backend/crud/quotation_crud.py | 43 ++++----- .../backend/router/v1/quotation/protocol.py | 9 +- .../backend/router/v1/quotation/quotation.py | 6 -- .../backend/scripts/seed_demo_quotations.sql | 22 ++--- .../backend/services/quotation_service.py | 48 ++++------ .../backend/tests/test_quotation_anchoring.py | 46 +++++++--- .../front/src/api/generated/model/index.ts | 8 +- .../src/api/generated/model/quotationData.ts | 2 - .../model/quotationDataSupplierType.ts | 9 -- .../api/generated/model/reqCreateQuotation.ts | 2 - .../model/reqCreateQuotationSupplierType.ts | 8 -- .../generated/model/resLastSupplierType.ts | 17 ---- .../generated/model/resLastSupplierTypeMsg.ts | 8 -- .../model/resLastSupplierTypeQtNumber.ts | 8 -- .../model/resLastSupplierTypeSupplierType.ts | 9 -- .../src/api/generated/model/supplierType.ts | 4 +- .../src/api/generated/quotation/quotation.ts | 92 ------------------- .../QuotationDetailSheet/TargetPriceModal.tsx | 3 - .../components/QuotationDetailSheet/index.tsx | 2 - postgres-init/ENUM_TYPE.md | 4 +- postgres-init/init-data/init.sql | 3 +- schedules/anchoring/src/anchoring/batch.py | 15 ++- .../anchoring/src/anchoring/constants.py | 6 +- schedules/anchoring/src/anchoring/models.py | 15 ++- schedules/anchoring/tests/conftest.py | 13 ++- 35 files changed, 157 insertions(+), 333 deletions(-) delete mode 100644 negodata/front/src/api/generated/model/quotationDataSupplierType.ts delete mode 100644 negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts delete mode 100644 negodata/front/src/api/generated/model/resLastSupplierType.ts delete mode 100644 negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts delete mode 100644 negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts delete mode 100644 negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py index 24e5d81..3dee8dc 100644 --- a/agent/negotiation/chat/infra/repository/nego_context_crud.py +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -28,7 +28,6 @@ _SESSIONS = table( ) _ITEMS = table("items", column("item_id"), column("price"), column("deleted"), schema="partner") _SUPPLIERS = table("suppliers", column("supplier_id"), column("total_revenue"), column("deleted"), schema="partner") -_QUOTATIONS = table("quotations", column("qt_id"), column("supplier_type"), column("deleted"), schema="quotation") # 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType). _SUPPLIER_ITEMS = table( "supplier_items", @@ -59,11 +58,6 @@ class INegoContextCRUD(ABC): 매핑이 없으면 None.""" pass - @abstractmethod - async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]: - """견적의 협력사 유형(quotations.supplier_type — supplier_items 매핑 부재 시 폴백). 미지정 시 None.""" - pass - @abstractmethod async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: """상품에 연결된 협력사 수 — supplier_items 매핑 기준 distinct supplier.""" @@ -139,21 +133,6 @@ class NegoContextCRUD(INegoContextCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None - async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]: - try: - query = ( - select(_QUOTATIONS.c.supplier_type) - .where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712 - .limit(1) - ) - err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_quotation_supplier_type failed.", raise_error=False) - if err_type != ErrorType.SUCCESS or not rows or rows[0] is None: - return err_type, None - return ErrorType.SUCCESS, int(rows[0]) - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, None - async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: try: query = ( diff --git a/agent/negotiation/chat/service/negotiation_context_loader.py b/agent/negotiation/chat/service/negotiation_context_loader.py index 0bc7bb4..d605950 100644 --- a/agent/negotiation/chat/service/negotiation_context_loader.py +++ b/agent/negotiation/chat/service/negotiation_context_loader.py @@ -27,8 +27,7 @@ _ONE_TO_ONE_QT_TYPES = (1, 3) # 유통 코드: SupplierType(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판) # → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값). -# 소스 우선순위: partner.supplier_items.supply_type(이 협력사×이 상품 매핑, 2026-07-07 신설) -# → quotations.supplier_type(재견적 1:1 견적 기록 — 매핑 부재 시 폴백). +# 소스: partner.supplier_items.supply_type(이 협력사×이 상품 매핑). _SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"} @@ -42,7 +41,7 @@ class NegotiationDbContext: item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0 partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0 - distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type → quotations.supplier_type. 미지정 시 None + distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None class NegotiationContextLoader: @@ -62,7 +61,7 @@ class NegotiationContextLoader: err, row = await self.crud.get_session_row(s, sid) if err != ErrorType.SUCCESS or row is None: return None - qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row + qt_type, target_price, anchoring_price, item_id, _quotation_id, supplier_id = row target = int(target_price or 0) # 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변. @@ -76,11 +75,9 @@ class NegotiationContextLoader: # 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값. _, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id) - # 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type) 우선. - # 매핑이 없으면 견적 기록(quotations.supplier_type) 폴백. 미지정 시 None → 호출부 기본값. + # 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type). + # 매핑이 없거나 미지정이면 None → 호출부 기본값. _, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id) - if not supplier_type: - _, supplier_type = await self.crud.get_quotation_supplier_type(s, quotation_id) # 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시). _, item_price = await self.crud.get_item_price(s, item_id) diff --git a/agent/router/v1/chat/protocol.py b/agent/router/v1/chat/protocol.py index 64e4187..166c313 100644 --- a/agent/router/v1/chat/protocol.py +++ b/agent/router/v1/chat/protocol.py @@ -13,7 +13,7 @@ class Req_Chat(Req_WebPacketProtocol): 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형)는 요청에 싣지 않는다 — 세션 시작 시 agent 가 DB 에서 1회 조회해 확정한다(NegotiationContextLoader): negotiation.sessions(qt_type·target_price·anchoring_price), partner.items(price), - partner.suppliers(total_revenue), quotation.quotations(supplier_type), 상품별 협력사 수. + partner.suppliers(total_revenue), partner.supplier_items(supply_type), 상품별 협력사 수. 행이 없으면(데모/테스트 직접 호출) 기본값 폴백. 가격 수용률은 세션 내 라운드별 제시가로 매 턴 동적 계산: max(0, (기존 공급가−현재가)/기존 공급가) — 첫 제시가부터 기존 공급가 대비 인하가 반영되므로 첫 라운드도 실값. 기존 공급가 없으면 첫 제시가 기준. diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 88df879..bf83ef2 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -31,7 +31,7 @@ _DEFAULT_RQ_TYPE = "재협상" _DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가 _DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상 _DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백 -_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — quotations.supplier_type 미지정 시 폴백 +_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백 class ChatService: @@ -69,7 +69,7 @@ class ChatService: session_id=req.session_id or str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id, rq_type=rq_type, action_space_size=engine.action_space_size, context={ - # 매출액 = suppliers.total_revenue, 유통코드 = quotations.supplier_type 매핑 (loader). + # 매출액 = suppliers.total_revenue, 유통코드 = supplier_items.supply_type 매핑 (loader). # 미기재/미지정이면 기본값 폴백. "revenue_amount": db_ctx.revenue_amount if db_ctx and db_ctx.revenue_amount > 0 else _DEFAULT_REVENUE_AMOUNT, "distribution_code": db_ctx.distribution_code if db_ctx and db_ctx.distribution_code else _DEFAULT_DISTRIBUTION_CODE, diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py index 034e597..f51c851 100644 --- a/agent/tests/test_context_loader.py +++ b/agent/tests/test_context_loader.py @@ -34,7 +34,7 @@ _T_QUOTATIONS = table( "quotations", column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"), column("name"), column("number"), column("type"), column("status"), - column("start_time"), column("end_time"), column("supplier_type"), + column("start_time"), column("end_time"), schema="quotation", ) _T_ITEMS = table( @@ -80,7 +80,6 @@ async def test_context_loaded_from_db(db_engine): qt_id=qid, user_id=uuid.uuid4(), qt_setting_id=uuid.uuid4(), version_id=uuid.uuid4(), name="로더 테스트", number="QT-LOADER-TEST", type=3, status=2, start_time=now, end_time=now + timedelta(days=1), - supplier_type=2, # manufacture(제조) → 유통 코드 "A" )) def _ins_sess(s, session_id, supplier_id): @@ -125,7 +124,7 @@ async def test_context_loaded_from_db(db_engine): assert c["distribution_code"] == "B" # supplier_items.supply_type=3(총판) → B (매핑 우선) assert c["partner_count"] == 2 # 매핑 기준 취급 협력사 2곳 → MULTIPLE - # 매핑 삭제 후 새 세션(sid2) → 폴백 경로: 유통코드=quotations.supplier_type, 파트너=세션 이력 + # 매핑 삭제 후 새 세션(sid2) → 유통코드는 ChatService 기본값, 파트너는 세션 이력 폴백 err = await DB_SESSION_MNG.execute_lambda_run( [DBType.MAIN.value], [lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIER_ITEMS).where(_T_SUPPLIER_ITEMS.c.item_id == iid))], @@ -133,7 +132,7 @@ async def test_context_loaded_from_db(db_engine): assert err == ErrorType.SUCCESS await ChatService().chat(eng, Req_Chat(session_id=str(sid2))) c2 = (await ChatSessionRepository(eng.company_id).get(str(sid2))).context - assert c2["distribution_code"] == "A" # 폴백: quotations.supplier_type=2(제조) → A + assert c2["distribution_code"] == "A" # 매핑 없음 → ChatService 기본값 assert c2["partner_count"] == 2 # 폴백: 세션 이력 distinct supplier 2곳 finally: await DB_SESSION_MNG.execute_lambda_run( @@ -197,9 +196,6 @@ async def test_loader_with_crud_double(db_engine): return ErrorType.SUCCESS, 12_000_000.0 async def get_supply_type(self, cdb, supplier_id, item_id): - return ErrorType.SUCCESS, None # 매핑 없음 → 견적 기록 폴백 - - async def get_quotation_supplier_type(self, cdb, quotation_id): return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B" async def count_item_suppliers(self, cdb, item_id): @@ -215,7 +211,7 @@ async def test_loader_with_crud_double(db_engine): assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target) assert ctx.item_price == 7000 assert ctx.revenue_amount == 12_000_000.0 - assert ctx.distribution_code == "B" # supplier_type=3(총판) → B + assert ctx.distribution_code == "B" # supply_type=3(총판) → B assert ctx.partner_type is PartnerType.NONE diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index bca3a1a..d7220ec 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -152,7 +152,6 @@ class quotations(MAIN_BASE): manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처 memo = Column(String(100), nullable=True) # 메모 md_price = Column(BigInteger, nullable=True) - supplier_type = Column(SmallInteger, nullable=True) iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수 preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) diff --git a/backend/tests/test_anchoring_chat.py b/backend/tests/test_anchoring_chat.py index 3aaede0..779355d 100644 --- a/backend/tests/test_anchoring_chat.py +++ b/backend/tests/test_anchoring_chat.py @@ -105,10 +105,15 @@ async def anchor_seed(db_engine): {"iid": item_id, "name": f"앵커상품 {code}", "code": f"{MARK}{code}"}, ) await conn.execute( - text("INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time, supplier_type) " - "VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 1, 2, now(), now() + interval '2 hours', 1)"), + text("INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) " + "VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 1, 2, now(), now() + interval '2 hours')"), {"qid": qt_id, "name": f"앵커견적 {code}", "num": f"{MARK}{code}"}, ) + await conn.execute( + text("INSERT INTO partner.supplier_items (supplier_item_id, supplier_id, item_id, supply_type) " + "VALUES (gen_random_uuid(), :sid, :iid, 1)"), + {"sid": supplier_id, "iid": item_id}, + ) await conn.execute( text("INSERT INTO negotiation.sessions " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " diff --git a/negodata/backend/common/anchoring/reader.py b/negodata/backend/common/anchoring/reader.py index ac7a93b..b82ab48 100644 --- a/negodata/backend/common/anchoring/reader.py +++ b/negodata/backend/common/anchoring/reader.py @@ -25,21 +25,23 @@ _current_values = table( ) -async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_type: int) -> dict: - """칸별 현재 앵커링 값 일괄 조회. {(company_id, price_range_index): rate‰} 반환. +async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_types: list[int]) -> dict: + """칸별 현재 앵커링 값 일괄 조회. {(company_id, supplier_type, price_range_index): rate‰} 반환. - supplier_type 은 견적 단위로 하나뿐이라 키에 넣지 않는다. + supplier_type 은 세션의 (item_id, supplier_id) 매핑에서 온 supply_type 이다. 조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 시 빈 dict.""" - if not company_ids or supplier_type not in SAMPLEABLE_SUPPLIER_TYPES: + valid_types = sorted({int(t) for t in supplier_types if t in SAMPLEABLE_SUPPLIER_TYPES}) + if not company_ids or not valid_types: return {} try: stmt = select( _current_values.c.company_id, + _current_values.c.supplier_type, _current_values.c.price_range_index, _current_values.c.anchoring_value, ).where( _current_values.c.company_id.in_(company_ids), - _current_values.c.supplier_type == supplier_type, + _current_values.c.supplier_type.in_(valid_types), ) rows = (await db.execute(stmt)).all() except Exception as ex: @@ -47,9 +49,9 @@ async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_typ return {} out = {} - for company_id, price_range_index, rate in rows: + for company_id, supplier_type, price_range_index, rate in rows: if not ANCHORING_VALUE_MIN <= rate <= ANCHORING_VALUE_MAX: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백 - LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} bracket={price_range_index} rate={rate}") + LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} type={supplier_type} bracket={price_range_index} rate={rate}") continue - out[(company_id, price_range_index)] = rate + out[(company_id, supplier_type, price_range_index)] = rate return out diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 48e7eaf..135183f 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -135,7 +135,7 @@ class supplier_items(MainTableMixin, MAIN_BASE): supplier_item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # suppliers.supplier_id item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # items.item_id - # SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). quotations.supplier_type 와 값은 같으나 의미 단위가 (협력사,상품)이라 컬럼명은 supply_type. + # SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). supply_type = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) @@ -229,8 +229,6 @@ class quotations(MainTableMixin, MAIN_BASE): manager_contact_number = Column(String(20), nullable=True) memo = Column(String(100), nullable=True) md_price = Column(BigInteger, nullable=True) - supplier_type = Column(SmallInteger, nullable=True) - iteration = Column(Integer, nullable=False, default=0) preferred_sp_yn = Column(Boolean, nullable=True) preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 80410ee..e735dfa 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -237,8 +237,8 @@ class CardType(CodeEnum): class SupplierType(CodeEnum): - """quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). - 없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응.""" + """partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). + 없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다.""" NONE = 0 # 없음(미지정) DISTRIBUTION = 1 # 유통 diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 3a8c6b8..17caad6 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import ( quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings, - version_nego_cards, version_wild_cards, users, + version_nego_cards, version_wild_cards, users, supplier_items, ) from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus from common.logger import LOG @@ -44,7 +44,7 @@ class IQuotationCRUD(ABC): pass @abstractmethod - async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]: + async def get_supply_types(self, cdb: AsyncSession, item_ids, supplier_ids) -> Tuple[ErrorType, dict]: pass @abstractmethod @@ -400,33 +400,30 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, {} - async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]: - """협력사의 직전 견적 supplier_type. (supplier_type, qt_number) | None. - sessions(supplier_id) ⨝ quotations 에서 supplier_type 가 있는 최신 견적 1건.""" + async def get_supply_types(self, cdb: AsyncSession, item_ids, supplier_ids) -> Tuple[ErrorType, dict]: + """(item_id, supplier_id) -> supplier_items.supply_type 매핑. + + 매핑이 없거나 supply_type 이 0/NULL 이면 호출측이 정적 앵커링 값으로 폴백한다. + """ try: - conds = [ - sessions.supplier_id == supplier_id, - quotations.supplier_type.isnot(None), - quotations.deleted == False, # noqa: E712 - ] - if company_id is not None: - conds.append(quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id))) - query = ( - select(quotations.supplier_type, quotations.number) - .join(sessions, sessions.quotation_id == quotations.qt_id) - .where(*conds) - .order_by(quotations.created_at.desc()) - .limit(1) + if not item_ids or not supplier_ids: + return ErrorType.SUCCESS, {} + query = select( + supplier_items.item_id, + supplier_items.supplier_id, + supplier_items.supply_type, + ).where( + supplier_items.item_id.in_(item_ids), + supplier_items.supplier_id.in_(supplier_ids), + supplier_items.deleted == False, # noqa: E712 ) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: - return err_type, None - if not rows: - return ErrorType.SUCCESS, None - return ErrorType.SUCCESS, (rows[0][0], rows[0][1]) + return err_type, {} + return ErrorType.SUCCESS, {(r[0], r[1]): r[2] for r in rows} except Exception as ex: LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, None + return ErrorType.DB_RUN_FAILED, {} async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]: """견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수). diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index d15aa9a..571862f 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -4,7 +4,7 @@ from typing import Any, Optional from pydantic import ConfigDict -from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus, SupplierType +from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol @@ -26,7 +26,6 @@ class Req_CreateQuotation(QuotationProtocol): manager_contact_number: Optional[str] = None memo: Optional[str] = None md_price: Optional[int] = None # MD 제시가(원). 세션 목표가 산정 최우선값 - supplier_type: Optional[int] = None # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성 supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사 card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결 @@ -63,7 +62,6 @@ class QuotationData(WebPacketProtocol): manager_contact_number: Optional[str] = None memo: Optional[str] = None md_price: Optional[int] = None - supplier_type: Optional[SupplierType] = None iteration: int = 0 preferred_sp_yn: Optional[bool] = None preferred_sp_id: Optional[uuid.UUID] = None @@ -189,11 +187,6 @@ class Res_QuotationCards(Res_WebPacketProtocol): cards: list[QuotationCardData] = [] -class Res_LastSupplierType(Res_WebPacketProtocol): - supplier_type: Optional[SupplierType] = None # 협력사 직전 견적의 유형(없으면 None) - qt_number: Optional[str] = None # 그 견적의 번호(이전 견적 값임을 표시용) - - class TargetCandidate(WebPacketProtocol): basis: str label: str diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index b853984..e5cc288 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -12,7 +12,6 @@ from .protocol import ( Req_RegenerateQuotation, Res_CreateQuotation, Res_DeleteQuotation, - Res_LastSupplierType, Res_NotifySessions, Res_Quotation, Res_QuotationCards, @@ -122,11 +121,6 @@ async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), u return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role)) -@router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형") -async def get_supplier_last_type(supplier_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.get_last_supplier_type(str(supplier_id), user_info.company_id)) - - # ----- 단건 조회 (정적/하위 경로 뒤에 선언) ----- @router.get(path="/{qt_id}", response_model=Res_Quotation, summary="견적 조회") async def get_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): diff --git a/negodata/backend/scripts/seed_demo_quotations.sql b/negodata/backend/scripts/seed_demo_quotations.sql index 90d7755..45e2ee6 100644 --- a/negodata/backend/scripts/seed_demo_quotations.sql +++ b/negodata/backend/scripts/seed_demo_quotations.sql @@ -34,7 +34,7 @@ INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, start_time, end_time, manager_name, manager_email, manager_contact_number, memo, iteration, preferred_sp_yn, preferred_sp_id, preferred_sp_name, equal_bid_yn, equal_bid_data, - close_reason, md_price, supplier_type, created_at, updated_at, deleted) + close_reason, md_price, created_at, updated_at, deleted) VALUES -- ① 낙찰(절감): 목표 400,000 → 낙찰 372,000 ('aaaa0001-0000-0000-0000-000000000001','b874f33d-afc3-4498-a9c2-19bc5ae8faba', @@ -42,14 +42,14 @@ VALUES '① 낙찰(절감) 데모','EST-DEMO-01',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰·목표대비 절감 케이스', 1, true,'9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','(주)대한정밀', null, null, - 1, 400000, 1, now(), now(), false), + 1, 400000, now(), now(), false), -- ② 낙찰(목표초과): 목표 350,000 → 낙찰 368,000 (over_action=낙찰) ('aaaa0002-0000-0000-0000-000000000002','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '② 낙찰(목표초과) 데모','EST-DEMO-02',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰이지만 목표가 초과 케이스', 1, true,'69e7057a-5919-48cd-b329-13b833bff994','서울산업소재(주)', null, null, - 1, 350000, 1, now(), now(), false), + 1, 350000, now(), now(), false), -- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행 ('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', @@ -57,42 +57,42 @@ VALUES now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가 발생·다음 라운드 재입찰 케이스', 1, false, null, null, true, '[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":390000},{"supplier_id":"747897ec-adab-4f3e-9076-15803ddf821f","supplier_name":"글로벌테크놀로지","bid_price":390000}]'::jsonb, - 6, 400000, 1, now(), now(), false), + 6, 400000, now(), now(), false), -- ④ 거부 유찰(FAIL_REJECT): 협상거부로 마감 ('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '④ 거부 개찰 데모','EST-DEMO-04',2,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스', 1, null, null, null, null, null, - 8, 400000, 1, now(), now(), false), + 8, 400000, now(), now(), false), -- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집 ('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑤ 미응찰 개찰 데모','EST-DEMO-05',1,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스', 1, false, null, null, false, null, - 7, 400000, 1, now(), now(), false), + 7, 400000, now(), now(), false), -- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL) ('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑥ 진행중 데모','EST-DEMO-06',4,1,2, now()-interval '1 day', now()+interval '1 day','김담당','mgr@imk.kr','010-1111-1111','협상 진행중·현재 최저 투찰 케이스', 1, null, null, null, null, null, - null, 400000, 1, now(), now(), false), + null, 400000, now(), now(), false), -- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행 ('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑦ 목표초과 개찰 데모','EST-DEMO-07',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스', 1, false, null, null, false, null, - 5, 350000, 1, now(), now(), false), + 5, 350000, now(), now(), false), -- ⑧ 가격 유찰(FAIL_PRICE): 단독 최저가 430,000 > 목표 350,000, over_action=유찰(또는 한도소진) → 유찰 ('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑧ 목표초과 개찰 데모(2)','EST-DEMO-08',4,1,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스', 1, false, null, null, false, null, - 5, 350000, 1, now(), now(), false), + 5, 350000, now(), now(), false), -- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰 ('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', @@ -100,14 +100,14 @@ VALUES now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가지만 재입찰 한도 소진 → 유찰 케이스', 1, false, null, null, true, '[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":405000},{"supplier_id":"69e7057a-5919-48cd-b329-13b833bff994","supplier_name":"서울산업소재(주)","bid_price":405000}]'::jsonb, - 6, 400000, 1, now(), now(), false), + 6, 400000, now(), now(), false), -- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰 ('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '⑩ 미응찰 개찰 데모(2)','EST-DEMO-10',1,2,3, now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스', 1, false, null, null, false, null, - 7, 400000, 1, now(), now(), false); + 7, 400000, now(), now(), false); -- ── 세션 ──────────────────────────────────────────────────────────────── -- 공통: item=a3d1fff5, DeliveryType 협력사배송=1 diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 0bdc31f..34fec7f 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -29,7 +29,6 @@ from router.v1.quotation.protocol import ( Req_CreateQuotation, Res_CreateQuotation, Res_DeleteQuotation, - Res_LastSupplierType, Res_NotifySessions, Res_Quotation, Res_QuotationCards, @@ -255,22 +254,6 @@ class QuotationService: res.quotation = QuotationData.model_validate(quotation) return res - async def get_last_supplier_type(self, supplier_id: str, company_id=None) -> Res_LastSupplierType: - """협력사의 직전 견적 supplier_type(견적생성 모달 프리필용). 이력 없으면 비워서 반환.""" - res = Res_LastSupplierType() - err_type, got = await DB_SESSION_MNG.execute_lambda( - quotations.DBType(), - DBWRType.DB_READ.value, - lambda s: self.quotation_crud.get_last_supplier_type(s, uuid.UUID(supplier_id), company_id), - ) - if err_type != ErrorType.SUCCESS: - res.result.SetResult(err_type) - return res - if got: - res.supplier_type = got[0] - res.qt_number = got[1] - return res - async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation: """[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다. 생성 성공 시 작성자에게 CREATED 알림(인박스).""" @@ -291,7 +274,6 @@ class QuotationService: manager_contact_number=req.manager_contact_number, memo=req.memo, md_price=req.md_price, - supplier_type=req.supplier_type, item_ids=req.item_ids, supplier_ids=req.supplier_ids, card_ids=req.card_ids, @@ -370,7 +352,6 @@ class QuotationService: manager_contact_number=original.manager_contact_number, memo=original.memo, md_price=original.md_price, - supplier_type=original.supplier_type, item_ids=item_ids, supplier_ids=list(supplier_ids), card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용) @@ -383,7 +364,7 @@ class QuotationService: self, *, user_id: str, qt_setting_id, version_id, name: str, number: str, type_: int, status: int, round_: int, start_time, end_time, - manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type, + manager_name, manager_email, manager_contact_number, memo, md_price, item_ids: list, supplier_ids: list, card_ids: list, mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN) over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN) @@ -418,7 +399,7 @@ class QuotationService: fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 # 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) — - # 칸(회사×협력사유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②. + # 칸(회사×상품-협력사 공급유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②. # 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다. # (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.) @@ -465,7 +446,6 @@ class QuotationService: manager_contact_number=manager_contact_number, memo=memo, md_price=md_price, - supplier_type=supplier_type, mid_action=mid_action, over_action=over_action, ) @@ -491,8 +471,8 @@ class QuotationService: res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE) return res - # ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) anchoring_value 조회 후 - # 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는 + # ② 앵커가 산출 — 칸(items.company_id × supplier_items.supply_type × 목표가 구간) anchoring_value 조회 후 + # 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 매핑 미지정/조정 이력 없음/조회 실패는 # 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 6). _err, item_companies = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), @@ -500,12 +480,19 @@ class QuotationService: lambda s: self.quotation_crud.get_item_companies(s, item_ids), ) item_companies = item_companies if _err == ErrorType.SUCCESS else {} + _err, supply_types = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_supply_types(s, item_ids, supplier_ids), + ) + supply_types = supply_types if _err == ErrorType.SUCCESS else {} value_map = {} - if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies: + sampleable_supply_types = sorted({t for t in supply_types.values() if t in SAMPLEABLE_SUPPLIER_TYPES}) + if sampleable_supply_types and item_companies: value_map = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: fetch_current_values(s, list(set(item_companies.values())), supplier_type), + lambda s: fetch_current_values(s, list(set(item_companies.values())), sampleable_supply_types), ) session_objs = [] @@ -513,11 +500,12 @@ class QuotationService: tp = target_prices[iid] price_range = calc_price_range_index(tp) company = item_companies.get(iid) - value = value_map.get((company, price_range)) if company is not None else None - if value is None: - value = get_base_anchoring_value(price_range) - ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성) for sid in supplier_ids: + supply_type = supply_types.get((iid, sid)) + value = value_map.get((company, supply_type, price_range)) if company is not None else None + if value is None: + value = get_base_anchoring_value(price_range) + ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성) session_objs.append( sessions( session_id=uuid.uuid4(), diff --git a/negodata/backend/tests/test_quotation_anchoring.py b/negodata/backend/tests/test_quotation_anchoring.py index 7ef8947..54120d8 100644 --- a/negodata/backend/tests/test_quotation_anchoring.py +++ b/negodata/backend/tests/test_quotation_anchoring.py @@ -1,8 +1,8 @@ -"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증. +"""앵커링 v1.2 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증. 이식 명세: schedules/anchoring/docs/인수인계.md §1. - 앵커가 = 목표가 × (1000 − anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제 -- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백, +- 조정 이력 없음 / 매핑 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백, 견적 생성은 실패하지 않는다(규칙 6) - 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지) """ @@ -22,12 +22,12 @@ BASE_VALUE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engine, company_id): - """검증: anchoring 스키마가 아예 없는 DB 에서 supplier_type=1(유통) 견적 생성. + """검증: anchoring 스키마가 아예 없는 DB 에서 supply_type=1(유통) 매핑으로 견적 생성. 기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), anchoring_value=10 박제.""" await _drop_anchoring(db_engine) item = await _seed_item(db_engine, company_id, internet_lowest=100_000) - res = await _create(item_ids=[item], supplier_type=1) + res = await _create(db_engine, item_ids=[item], supply_type=1) assert res.result.success is True tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200 @@ -45,7 +45,7 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id) tp_miss = int(5_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp_hit), value_after=50) - res = await _create(item_ids=[item_hit, item_miss], supplier_type=1) + res = await _create(db_engine, item_ids=[item_hit, item_miss], supply_type=1) assert res.result.success is True rows = await _session_anchor_rows(db_engine, res.qt_id) @@ -53,15 +53,15 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id) assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE) -async def test_supplier_type_unset_uses_base_value(db_engine, company_id): - """검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가. +async def test_supply_type_unset_uses_base_value(db_engine, company_id): + """검증: supply_type 미지정(None) 매핑으로 견적 생성 — 칸(회사×유형×구간) 구성 불가. 기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10‰ 박제.""" await _reset_anchoring(db_engine) item = await _seed_item(db_engine, company_id, internet_lowest=100_000) tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50) - res = await _create(item_ids=[item], supplier_type=None) + res = await _create(db_engine, item_ids=[item], supply_type=None) assert res.result.success is True rows = await _session_anchor_rows(db_engine, res.qt_id) @@ -75,7 +75,7 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa item = await _seed_item(db_engine, company_id, internet_lowest=100_000) supplier = uuid.uuid4() - res1 = await _create(item_ids=[item], supplier_type=1, supplier_ids=[supplier]) + res1 = await _create(db_engine, item_ids=[item], supply_type=1, supplier_ids=[supplier]) assert res1.result.success is True tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) rows1 = await _session_anchor_rows(db_engine, res1.qt_id) @@ -108,20 +108,40 @@ def _service(): return QuotationService(QuotationCRUD()) -async def _create(*, item_ids, supplier_type, supplier_ids=None): - """supplier_type 을 지정해 견적 1건 생성(공급사 기본 1곳).""" +async def _create(engine, *, item_ids, supply_type, supplier_ids=None): + """supplier_items.supply_type 매핑을 시드한 뒤 견적 1건 생성(공급사 기본 1곳).""" + supplier_ids = supplier_ids or [uuid.uuid4()] + await _seed_supplier_items(engine, item_ids, supplier_ids, supply_type=supply_type) req = Req_CreateQuotation( qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(앵커는 세팅과 무관해짐) name="앵커링검증", type=QuotationType.NEW_QUOTE.value, end_time=FUTURE, - supplier_type=supplier_type, item_ids=list(item_ids), - supplier_ids=supplier_ids or [uuid.uuid4()], + supplier_ids=supplier_ids, ) return await _service().create_quotation(str(uuid.uuid4()), req) +async def _seed_supplier_items(engine, item_ids, supplier_ids, *, supply_type): + async with engine.begin() as conn: + for item_id in item_ids: + for supplier_id in supplier_ids: + await conn.execute( + text( + "INSERT INTO supplier_items " + "(supplier_item_id, supplier_id, item_id, supply_type) " + "VALUES (:id, :supplier_id, :item_id, :supply_type)" + ), + { + "id": uuid.uuid4(), + "supplier_id": supplier_id, + "item_id": item_id, + "supply_type": supply_type if supply_type is not None else 0, + }, + ) + + async def _seed_item(engine, company_id, *, internet_lowest): """상품 1건 시드(인터넷최저가만). NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음).""" item_id = uuid.uuid4() diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 6068205..65b0c7a 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -114,7 +114,6 @@ export * from './quotationDataOverAction'; export * from './quotationDataPreferredSpId'; export * from './quotationDataPreferredSpName'; export * from './quotationDataPreferredSpYn'; -export * from './quotationDataSupplierType'; export * from './quotationDataUpdatedAt'; export * from './quotationSettingData'; export * from './quotationSettingDataCreatedAt'; @@ -161,7 +160,6 @@ export * from './reqCreateQuotationMidAction'; export * from './reqCreateQuotationOverAction'; export * from './reqCreateQuotationSetting'; export * from './reqCreateQuotationStartTime'; -export * from './reqCreateQuotationSupplierType'; export * from './reqCreateQuotationVersionId'; export * from './reqCreateSupplier'; export * from './reqCreateSupplierCode'; @@ -269,10 +267,6 @@ export * from './resItemListMsg'; export * from './resItemMsg'; export * from './resItemSupplyTypeList'; export * from './resItemSupplyTypeListMsg'; -export * from './resLastSupplierType'; -export * from './resLastSupplierTypeMsg'; -export * from './resLastSupplierTypeQtNumber'; -export * from './resLastSupplierTypeSupplierType'; export * from './resLogin'; export * from './resLoginMsg'; export * from './resLowestPriceResult'; @@ -380,4 +374,4 @@ export * from './userRole'; export * from './userStatus'; export * from './validationError'; export * from './validationErrorCtx'; -export * from './validationErrorLocItem'; \ No newline at end of file +export * from './validationErrorLocItem'; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index 3b3f5fd..cdfd361 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -11,7 +11,6 @@ import type { QuotationDataManagerEmail } from './quotationDataManagerEmail'; import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber'; import type { QuotationDataMemo } from './quotationDataMemo'; import type { QuotationDataMdPrice } from './quotationDataMdPrice'; -import type { QuotationDataSupplierType } from './quotationDataSupplierType'; import type { QuotationDataPreferredSpYn } from './quotationDataPreferredSpYn'; import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId'; import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName'; @@ -43,7 +42,6 @@ export interface QuotationData { manager_contact_number?: QuotationDataManagerContactNumber; memo?: QuotationDataMemo; md_price?: QuotationDataMdPrice; - supplier_type?: QuotationDataSupplierType; iteration?: number; preferred_sp_yn?: QuotationDataPreferredSpYn; preferred_sp_id?: QuotationDataPreferredSpId; diff --git a/negodata/front/src/api/generated/model/quotationDataSupplierType.ts b/negodata/front/src/api/generated/model/quotationDataSupplierType.ts deleted file mode 100644 index 8f47105..0000000 --- a/negodata/front/src/api/generated/model/quotationDataSupplierType.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ -import type { SupplierType } from './supplierType'; - -export type QuotationDataSupplierType = SupplierType | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotation.ts b/negodata/front/src/api/generated/model/reqCreateQuotation.ts index c750921..42b2b65 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotation.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotation.ts @@ -11,7 +11,6 @@ import type { ReqCreateQuotationManagerEmail } from './reqCreateQuotationManager import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber'; import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo'; import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice'; -import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType'; import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction'; import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction'; @@ -29,7 +28,6 @@ export interface ReqCreateQuotation { manager_contact_number?: ReqCreateQuotationManagerContactNumber; memo?: ReqCreateQuotationMemo; md_price?: ReqCreateQuotationMdPrice; - supplier_type?: ReqCreateQuotationSupplierType; item_ids?: string[]; supplier_ids?: string[]; card_ids?: string[]; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts b/negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts deleted file mode 100644 index 38bc008..0000000 --- a/negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ - -export type ReqCreateQuotationSupplierType = number | null; diff --git a/negodata/front/src/api/generated/model/resLastSupplierType.ts b/negodata/front/src/api/generated/model/resLastSupplierType.ts deleted file mode 100644 index a6f3f29..0000000 --- a/negodata/front/src/api/generated/model/resLastSupplierType.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ -import type { ErrorInfo } from './errorInfo'; -import type { ResLastSupplierTypeMsg } from './resLastSupplierTypeMsg'; -import type { ResLastSupplierTypeSupplierType } from './resLastSupplierTypeSupplierType'; -import type { ResLastSupplierTypeQtNumber } from './resLastSupplierTypeQtNumber'; - -export interface ResLastSupplierType { - result?: ErrorInfo; - msg?: ResLastSupplierTypeMsg; - supplier_type?: ResLastSupplierTypeSupplierType; - qt_number?: ResLastSupplierTypeQtNumber; -} diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts b/negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts deleted file mode 100644 index ee369fc..0000000 --- a/negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ - -export type ResLastSupplierTypeMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts b/negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts deleted file mode 100644 index 01ac822..0000000 --- a/negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ - -export type ResLastSupplierTypeQtNumber = string | null; diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts b/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts deleted file mode 100644 index 313d621..0000000 --- a/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Negodata Api Server - * OpenAPI spec version: 0.1.0 - */ -import type { SupplierType } from './supplierType'; - -export type ResLastSupplierTypeSupplierType = SupplierType | null; diff --git a/negodata/front/src/api/generated/model/supplierType.ts b/negodata/front/src/api/generated/model/supplierType.ts index 38cc6ca..fe44d31 100644 --- a/negodata/front/src/api/generated/model/supplierType.ts +++ b/negodata/front/src/api/generated/model/supplierType.ts @@ -6,8 +6,8 @@ */ /** - * quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). -없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응. + * partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). +없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다. */ export type SupplierType = typeof SupplierType[keyof typeof SupplierType]; diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index d055c72..dc357b6 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -31,7 +31,6 @@ import type { ReqRegenerateQuotation, ResCreateQuotation, ResDeleteQuotation, - ResLastSupplierType, ResNotifySessions, ResQuotation, ResQuotationCards, @@ -1136,96 +1135,6 @@ export const useDeleteQuotation = ,signal?: AbortSignal -) => { - - - return customFetch( - {url: `/v1/quotation/supplier/${supplierId}/last-type`, method: 'GET', signal - }, - options); - } - - - - -export const getGetSupplierLastTypeQueryKey = (supplierId?: string,) => { - return [ - `/v1/quotation/supplier/${supplierId}/last-type` - ] as const; - } - - -export const getGetSupplierLastTypeQueryOptions = >, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetSupplierLastTypeQueryKey(supplierId); - - - - const queryFn: QueryFunction>> = ({ signal }) => getSupplierLastType(supplierId, requestOptions, signal); - - - - - - return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } -} - -export type GetSupplierLastTypeQueryResult = NonNullable>> -export type GetSupplierLastTypeQueryError = void | HTTPValidationError - - -export function useGetSupplierLastType>, TError = void | HTTPValidationError>( - supplierId: string, options: { query:Partial>, TError, TData>> & Pick< - DefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetSupplierLastType>, TError = void | HTTPValidationError>( - supplierId: string, options?: { query?:Partial>, TError, TData>> & Pick< - UndefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetSupplierLastType>, TError = void | HTTPValidationError>( - supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -/** - * @summary 협력사 직전 견적 유형 - */ - -export function useGetSupplierLastType>, TError = void | HTTPValidationError>( - supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getGetSupplierLastTypeQueryOptions(supplierId,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - query.queryKey = queryOptions.queryKey ; - - return query; -} - - /** @@ -1319,4 +1228,3 @@ export function useGetQuotation> - diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx index 6002e63..653f916 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -13,7 +13,6 @@ type TargetPriceModalProps = { vatYn?: boolean | null; deliveryFeeYn?: boolean | null; category?: string | null; - supplierTypeLabel: string; }; const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'); @@ -32,7 +31,6 @@ export function TargetPriceModal({ vatYn, deliveryFeeYn, category, - supplierTypeLabel, }: TargetPriceModalProps) { useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금 const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } }); @@ -130,7 +128,6 @@ export function TargetPriceModal({ {category && ( 서비스 카테고리: {category} )} - 공급 업체 유형: {supplierTypeLabel} 앵커링 값: {bd.anchoring_value} 앵커링가: {won(bd.anchoring_price)} = 목표가×(1−{bd.anchoring_value}) diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx index 869963f..1c85023 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx @@ -21,7 +21,6 @@ import { mapServerCardView, chainRoundState, } from '../../types'; -import { supplierTypeLabel } from '@/lib/enumLabels'; import { QuotationStatus } from '@/api/generated/model'; import { DrawerHeaderCards } from './DrawerHeaderCards'; import { RoundTimeline } from './RoundTimeline'; @@ -314,7 +313,6 @@ export function QuotationDetailSheet({ vatYn={currentItem.vat_yn} deliveryFeeYn={currentItem.delivery_fee_yn} category={currentItem.category} - supplierTypeLabel={supplierTypeLabel(quotation.supplier_type)} /> ); })()} diff --git a/postgres-init/ENUM_TYPE.md b/postgres-init/ENUM_TYPE.md index 56baab7..c6fec2d 100644 --- a/postgres-init/ENUM_TYPE.md +++ b/postgres-init/ENUM_TYPE.md @@ -105,7 +105,7 @@ ### SupplierType — 협력사(공급채널) 유형 -`quotation.quotations.supplier_type` · `partner.supplier_items.supply_type` · `anchoring.adjustments.supplier_type` +`partner.supplier_items.supply_type` · `anchoring.adjustments.supplier_type` | 값 | 코드명 | 의미 | |---|---|---| @@ -114,7 +114,7 @@ | 2 | MANUFACTURE | 제조 | | 3 | SOLE_AGENCY | 총판 | -anchoring.adjustments는 1~3만 사용(0 없음 — δ: 유통20/제조10/총판15). `supplier_items.supply_type`은 `quotations.supplier_type`과 의미 단위가 달라 컬럼명을 달리 씀(값 집합은 동일). +anchoring.adjustments는 1~3만 사용(0 없음 — δ: 유통20/제조10/총판15). 견적 생성/배치/agent는 상품-협력사 매핑의 `supplier_items.supply_type`을 기준으로 한다. ### NotificationType — 알림 유형 diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 1b130cc..b5b174e 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -291,7 +291,6 @@ CREATE TABLE IF NOT EXISTS quotation.quotations ( manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처 memo VARCHAR(100) NULL, -- 메모 md_price BIGINT NULL, -- MD 제시가(원). 목표가 산정 최우선값 (견적생성 모달 입력) - supplier_type SMALLINT NULL, -- 협력사 유형(SupplierType): 0=none(없음), 1=distribution(유통), 2=manufacture(제조), 3=sole_agency(총판). 재견적 1:1 견적에 기록 iteration INTEGER NOT NULL DEFAULT 0, -- 반복 횟수 preferred_sp_yn BOOLEAN NULL, -- 선호 공급사 지정 여부 preferred_sp_id uuid NULL, -- 선호 공급사(partner.suppliers.supplier_id) @@ -656,4 +655,4 @@ ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC; -- 멱등 ALTER 로 여기에 함께 둔다. 신규 DB 에는 전부 no-op. -- 새 스키마 변경 시 위 테이블 정의와 이 섹션을 동시에 갱신한다 (구 04-alter*.sql 의 역할). -- 기준선: 2026-07-07 main 스키마. 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용. - +ALTER TABLE quotation.quotations DROP COLUMN IF EXISTS supplier_type; diff --git a/schedules/anchoring/src/anchoring/batch.py b/schedules/anchoring/src/anchoring/batch.py index 0511d1c..7fcc35c 100644 --- a/schedules/anchoring/src/anchoring/batch.py +++ b/schedules/anchoring/src/anchoring/batch.py @@ -24,7 +24,7 @@ from anchoring.constants import ( ) from anchoring.db import session_scope from anchoring.log import LOG -from anchoring.models import Item, Quotation, Adjustment, Session +from anchoring.models import Item, Quotation, Adjustment, Session, SupplierItem from anchoring.reader import get_latest_adjusted_value from anchoring.redis_client import consume_failure_counts, ping, set_value from anchoring.service import calc_anchoring_price, calc_price_range_index, evaluate_samples, judge_sample_type @@ -76,9 +76,9 @@ async def _reconcile_cache() -> int: async def _scan_pending(db, company_ids: list | None = None) -> list: - """미처리 종료 재협상 세션 + 칸 해석 소스(supplier_type/company_id) 조인. §8 절차 1 + """미처리 종료 재협상 세션 + 칸 해석 소스(supply_type/company_id) 조인. §8 절차 1 - 조인 ON 절에 deleted 필터 — 소프트 삭제된 견적/상품의 세션은 칸 해석이 NULL 이 되어 + 조인 ON 절에 deleted 필터 — 소프트 삭제된 견적/상품/매핑의 세션은 칸 해석이 NULL 이 되어 제외 마킹(0)으로 정리된다(철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지). company_ids: 대상 회사 한정(테스트·표적 수동 실행용). None = 전체. """ @@ -91,7 +91,7 @@ async def _scan_pending(db, company_ids: list | None = None) -> list: Session.anchoring_price, Session.anchoring_value, Session.last_offer_price, - Quotation.supplier_type, + SupplierItem.supply_type.label("supplier_type"), Item.company_id, ) .join( @@ -104,6 +104,13 @@ async def _scan_pending(db, company_ids: list | None = None) -> list: (Item.item_id == Session.item_id) & Item.deleted.is_(False), isouter=True, ) + .join( + SupplierItem, + (SupplierItem.item_id == Session.item_id) + & (SupplierItem.supplier_id == Session.supplier_id) + & SupplierItem.deleted.is_(False), + isouter=True, + ) .where( Session.used_by_adjustment_id.is_(None), Session.deleted.is_(False), diff --git a/schedules/anchoring/src/anchoring/constants.py b/schedules/anchoring/src/anchoring/constants.py index caaaa97..6820522 100644 --- a/schedules/anchoring/src/anchoring/constants.py +++ b/schedules/anchoring/src/anchoring/constants.py @@ -1,7 +1,7 @@ """앵커링 도메인 상수 + 코드값(enum). 규범: docs/개발용.md §3. backend 를 import 하지 않고 자체 보유한다(자립 모듈). 코드값은 프로젝트 컨벤션 -(SMALLINT 1-based + 앱 enum 매핑)을 따르며 quotations.supplier_type 과 동일 코드다. +(SMALLINT 1-based + 앱 enum 매핑)을 따르며 supplier_items.supply_type 과 동일 코드다. 상수 변경은 정책 재확정 사안 — 코드에서 임의 조정 금지(§12). """ from enum import Enum @@ -11,7 +11,7 @@ ANCHORING_VALUE_MIN = 10 # 하한 1% ANCHORING_VALUE_MAX = 200 # 상한 20% # 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2) -# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드 +# 유형별 조정폭 (올림·내림 대칭). 키 = supplier_items.supply_type SMALLINT 코드 # ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름) ADJUSTMENT_STEP = { 1: 20, # 유통(DISTRIBUTION) ±2% @@ -50,7 +50,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백 class SupplierType(Enum): - """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type + """협력사 유형 코드. partner.supplier_items.supply_type / anchoring.adjustments.supplier_type (negodata SupplierType 과 동일 코드)""" NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외) DISTRIBUTION = 1 # 유통 diff --git a/schedules/anchoring/src/anchoring/models.py b/schedules/anchoring/src/anchoring/models.py index 10ce3cd..4423b65 100644 --- a/schedules/anchoring/src/anchoring/models.py +++ b/schedules/anchoring/src/anchoring/models.py @@ -2,7 +2,7 @@ - 소유(쓰기): anchoring.adjustments (append-only — UPDATE/DELETE 금지 §5) - sessions 는 used_by_adjustment_id 마킹만 쓰기 가능(그 외 컬럼 수정 금지 §12). - quotations/items 는 읽기 전용 경량 매핑(집계에 필요한 컬럼만). + quotations/items/supplier_items 는 읽기 전용 경량 매핑(집계에 필요한 컬럼만). """ from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text from sqlalchemy.dialects.postgresql import JSONB, UUID @@ -35,6 +35,7 @@ class Session(BASE): session_id = Column(UUID(as_uuid=True), primary_key=True) quotation_id = Column(UUID(as_uuid=True), nullable=False) item_id = Column(UUID(as_uuid=True), nullable=False) + supplier_id = Column(UUID(as_uuid=True), nullable=False) qt_type = Column(SmallInteger, nullable=False) # 1=재협상 target_price = Column(BigInteger, nullable=False) anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준) @@ -51,7 +52,6 @@ class Quotation(BASE): __table_args__ = {"schema": "quotation"} qt_id = Column(UUID(as_uuid=True), primary_key=True) - supplier_type = Column(SmallInteger, nullable=True) # NULL 이면 칸 구성 불가 → 제외 deleted = Column(Boolean, nullable=False) @@ -62,3 +62,14 @@ class Item(BASE): item_id = Column(UUID(as_uuid=True), primary_key=True) company_id = Column(UUID(as_uuid=True), nullable=True) # 테넌트(갑) — NULL 이면 칸 구성 불가 deleted = Column(Boolean, nullable=False) + + +class SupplierItem(BASE): + __tablename__ = "supplier_items" + __table_args__ = {"schema": "partner"} + + supplier_item_id = Column(UUID(as_uuid=True), primary_key=True) + supplier_id = Column(UUID(as_uuid=True), nullable=False) + item_id = Column(UUID(as_uuid=True), nullable=False) + supply_type = Column(SmallInteger, nullable=True) # 1유통/2제조/3총판. 0/NULL 이면 제외 + deleted = Column(Boolean, nullable=False) diff --git a/schedules/anchoring/tests/conftest.py b/schedules/anchoring/tests/conftest.py index 85be1b7..2aeaece 100644 --- a/schedules/anchoring/tests/conftest.py +++ b/schedules/anchoring/tests/conftest.py @@ -100,13 +100,18 @@ class Seeder: await db.execute(text( "INSERT INTO quotation.quotations " "(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, " - " start_time, end_time, supplier_type) " - "VALUES (:qid, :uid, :sid, :vid, 'anchoring-it-test', :num, :qtype, 1, 3, now(), now(), :stype)" + " start_time, end_time) " + "VALUES (:qid, :uid, :sid, :vid, 'anchoring-it-test', :num, :qtype, 1, 3, now(), now())" ), { "qid": qt_id, "uid": self.user_id, "sid": uuid.uuid4(), "vid": uuid.uuid4(), - "num": f"AT{uuid.uuid4().hex[:12]}", "qtype": qt_type, "stype": supplier_type, + "num": f"AT{uuid.uuid4().hex[:12]}", "qtype": qt_type, }) session_id = uuid.uuid4() + supplier_id = uuid.uuid4() + await db.execute(text( + "INSERT INTO partner.supplier_items (supplier_item_id, supplier_id, item_id, supply_type) " + "VALUES (:siid, :supid, :iid, :stype)" + ), {"siid": uuid.uuid4(), "supid": supplier_id, "iid": self.item_id, "stype": supplier_type or 0}) await db.execute(text( "INSERT INTO negotiation.sessions " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " @@ -114,7 +119,7 @@ class Seeder: " status, bid_price, end_time) " "VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :value, :lop, :status, :bid, now())" ), { - "sid": session_id, "qid": qt_id, "iid": self.item_id, "supid": uuid.uuid4(), + "sid": session_id, "qid": qt_id, "iid": self.item_id, "supid": supplier_id, "qtype": qt_type, "tp": target_price, "ap": anchoring_price, "value": anchoring_value, "lop": last_offer_price, "status": status, "bid": bid_price, })