[refactor] 공급유형 기준을 상품-협력사 매핑으로 전환
This commit is contained in:
parent
9ac8fe6363
commit
d21e7c10b0
@ -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 = (
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, (기존 공급가−현재가)/기존 공급가)
|
||||
— 첫 제시가부터 기존 공급가 대비 인하가 반영되므로 첫 라운드도 실값. 기존 공급가 없으면 첫 제시가 기준.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, "
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 # 유통
|
||||
|
||||
@ -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}. 목표가 산정 입력(인터넷 수수료는 상수).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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[];
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -31,7 +31,6 @@ import type {
|
||||
ReqRegenerateQuotation,
|
||||
ResCreateQuotation,
|
||||
ResDeleteQuotation,
|
||||
ResLastSupplierType,
|
||||
ResNotifySessions,
|
||||
ResQuotation,
|
||||
ResQuotationCards,
|
||||
@ -1136,96 +1135,6 @@ export const useDeleteQuotation = <TError = void | HTTPValidationError,
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 협력사 직전 견적 유형
|
||||
*/
|
||||
export const getSupplierLastType = (
|
||||
supplierId: string,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResLastSupplierType>(
|
||||
{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 = <TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSupplierLastTypeQueryKey(supplierId);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSupplierLastType>>> = ({ signal }) => getSupplierLastType(supplierId, requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetSupplierLastTypeQueryResult = NonNullable<Awaited<ReturnType<typeof getSupplierLastType>>>
|
||||
export type GetSupplierLastTypeQueryError = void | HTTPValidationError
|
||||
|
||||
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 협력사 직전 견적 유형
|
||||
*/
|
||||
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetSupplierLastTypeQueryOptions(supplierId,options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@ -1319,4 +1228,3 @@ export function useGetQuotation<TData = Awaited<ReturnType<typeof getQuotation>>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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 && (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
|
||||
)}
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {bd.anchoring_value}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
|
||||
앵커링가: {won(bd.anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{bd.anchoring_value})</span>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -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 — 알림 유형
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 # 유통
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user