[refactor] 공급유형 기준을 상품-협력사 매핑으로 전환

This commit is contained in:
Mina Choi 2026-07-07 17:04:23 +09:00
parent 9ac8fe6363
commit d21e7c10b0
35 changed files with 157 additions and 333 deletions

View File

@ -28,7 +28,6 @@ _SESSIONS = table(
) )
_ITEMS = table("items", column("item_id"), column("price"), column("deleted"), schema="partner") _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") _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). # 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
_SUPPLIER_ITEMS = table( _SUPPLIER_ITEMS = table(
"supplier_items", "supplier_items",
@ -59,11 +58,6 @@ class INegoContextCRUD(ABC):
매핑이 없으면 None.""" 매핑이 없으면 None."""
pass 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 @abstractmethod
async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
"""상품에 연결된 협력사 수 — supplier_items 매핑 기준 distinct supplier.""" """상품에 연결된 협력사 수 — supplier_items 매핑 기준 distinct supplier."""
@ -139,21 +133,6 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None 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]: async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
try: try:
query = ( query = (

View File

@ -27,8 +27,7 @@ _ONE_TO_ONE_QT_TYPES = (1, 3)
# 유통 코드: SupplierType(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판) # 유통 코드: SupplierType(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판)
# → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값). # → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값).
# 소스 우선순위: partner.supplier_items.supply_type(이 협력사×이 상품 매핑, 2026-07-07 신설) # 소스: partner.supplier_items.supply_type(이 협력사×이 상품 매핑).
# → quotations.supplier_type(재견적 1:1 견적 기록 — 매핑 부재 시 폴백).
_SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"} _SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"}
@ -42,7 +41,7 @@ class NegotiationDbContext:
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0 item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0 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: class NegotiationContextLoader:
@ -62,7 +61,7 @@ class NegotiationContextLoader:
err, row = await self.crud.get_session_row(s, sid) err, row = await self.crud.get_session_row(s, sid)
if err != ErrorType.SUCCESS or row is None: if err != ErrorType.SUCCESS or row is None:
return 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) target = int(target_price or 0)
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변. # 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
@ -76,11 +75,9 @@ class NegotiationContextLoader:
# 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값. # 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값.
_, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id) _, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id)
# 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type) 우선. # 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type).
# 매핑이 없으면 견적 기록(quotations.supplier_type) 폴백. 미지정 시 None → 호출부 기본값. # 매핑이 없거나 미지정이면 None → 호출부 기본값.
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id) _, 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(인하율 멘트 미표시). # 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
_, item_price = await self.crud.get_item_price(s, item_id) _, item_price = await self.crud.get_item_price(s, item_id)

View File

@ -13,7 +13,7 @@ class Req_Chat(Req_WebPacketProtocol):
협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형) 요청에 싣지 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형) 요청에 싣지
않는다 세션 시작 agent DB 에서 1 조회해 확정한다(NegotiationContextLoader): 않는다 세션 시작 agent DB 에서 1 조회해 확정한다(NegotiationContextLoader):
negotiation.sessions(qt_type·target_price·anchoring_price), partner.items(price), 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, (기존 공급가현재가)/기존 공급가) 가격 수용률은 세션 라운드별 제시가로 동적 계산: max(0, (기존 공급가현재가)/기존 공급가)
제시가부터 기존 공급가 대비 인하가 반영되므로 라운드도 실값. 기존 공급가 없으면 제시가 기준. 제시가부터 기존 공급가 대비 인하가 반영되므로 라운드도 실값. 기존 공급가 없으면 제시가 기준.

View File

@ -31,7 +31,7 @@ _DEFAULT_RQ_TYPE = "재협상"
_DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가 _DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가
_DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상 _DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
_DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백 _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: 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, 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, rq_type=rq_type, action_space_size=engine.action_space_size,
context={ 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, "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, "distribution_code": db_ctx.distribution_code if db_ctx and db_ctx.distribution_code else _DEFAULT_DISTRIBUTION_CODE,

View File

@ -34,7 +34,7 @@ _T_QUOTATIONS = table(
"quotations", "quotations",
column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"), column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"),
column("name"), column("number"), column("type"), column("status"), 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", schema="quotation",
) )
_T_ITEMS = table( _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(), 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, name="로더 테스트", number="QT-LOADER-TEST", type=3, status=2,
start_time=now, end_time=now + timedelta(days=1), start_time=now, end_time=now + timedelta(days=1),
supplier_type=2, # manufacture(제조) → 유통 코드 "A"
)) ))
def _ins_sess(s, session_id, supplier_id): 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["distribution_code"] == "B" # supplier_items.supply_type=3(총판) → B (매핑 우선)
assert c["partner_count"] == 2 # 매핑 기준 취급 협력사 2곳 → MULTIPLE assert c["partner_count"] == 2 # 매핑 기준 취급 협력사 2곳 → MULTIPLE
# 매핑 삭제 후 새 세션(sid2) → 폴백 경로: 유통코드=quotations.supplier_type, 파트너=세션 이력 # 매핑 삭제 후 새 세션(sid2) → 유통코드는 ChatService 기본값, 파트너는 세션 이력 폴백
err = await DB_SESSION_MNG.execute_lambda_run( err = await DB_SESSION_MNG.execute_lambda_run(
[DBType.MAIN.value], [DBType.MAIN.value],
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIER_ITEMS).where(_T_SUPPLIER_ITEMS.c.item_id == iid))], [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 assert err == ErrorType.SUCCESS
await ChatService().chat(eng, Req_Chat(session_id=str(sid2))) await ChatService().chat(eng, Req_Chat(session_id=str(sid2)))
c2 = (await ChatSessionRepository(eng.company_id).get(str(sid2))).context 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곳 assert c2["partner_count"] == 2 # 폴백: 세션 이력 distinct supplier 2곳
finally: finally:
await DB_SESSION_MNG.execute_lambda_run( 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 return ErrorType.SUCCESS, 12_000_000.0
async def get_supply_type(self, cdb, supplier_id, item_id): 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" return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B"
async def count_item_suppliers(self, cdb, item_id): 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.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.item_price == 7000 assert ctx.item_price == 7000
assert ctx.revenue_amount == 12_000_000.0 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 assert ctx.partner_type is PartnerType.NONE

View File

@ -152,7 +152,6 @@ class quotations(MAIN_BASE):
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처 manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
memo = Column(String(100), nullable=True) # 메모 memo = Column(String(100), nullable=True) # 메모
md_price = Column(BigInteger, nullable=True) md_price = Column(BigInteger, nullable=True)
supplier_type = Column(SmallInteger, nullable=True)
iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수 iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부 preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id) preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)

View File

@ -105,10 +105,15 @@ async def anchor_seed(db_engine):
{"iid": item_id, "name": f"앵커상품 {code}", "code": f"{MARK}{code}"}, {"iid": item_id, "name": f"앵커상품 {code}", "code": f"{MARK}{code}"},
) )
await conn.execute( 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) " 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', 1)"), "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}"}, {"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( await conn.execute(
text("INSERT INTO negotiation.sessions " text("INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "

View File

@ -25,21 +25,23 @@ _current_values = table(
) )
async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_type: int) -> dict: async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_types: list[int]) -> dict:
"""칸별 현재 앵커링 값 일괄 조회. {(company_id, price_range_index): rate‰} 반환. """칸별 현재 앵커링 값 일괄 조회. {(company_id, supplier_type, price_range_index): rate‰} 반환.
supplier_type 견적 단위로 하나뿐이라 키에 넣지 않는. supplier_type 세션의 (item_id, supplier_id) 매핑에서 supply_type .
조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 dict.""" 조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 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 {} return {}
try: try:
stmt = select( stmt = select(
_current_values.c.company_id, _current_values.c.company_id,
_current_values.c.supplier_type,
_current_values.c.price_range_index, _current_values.c.price_range_index,
_current_values.c.anchoring_value, _current_values.c.anchoring_value,
).where( ).where(
_current_values.c.company_id.in_(company_ids), _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() rows = (await db.execute(stmt)).all()
except Exception as ex: except Exception as ex:
@ -47,9 +49,9 @@ async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_typ
return {} return {}
out = {} 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: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백 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 continue
out[(company_id, price_range_index)] = rate out[(company_id, supplier_type, price_range_index)] = rate
return out return out

View File

@ -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_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 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 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) 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) manager_contact_number = Column(String(20), nullable=True)
memo = Column(String(100), nullable=True) memo = Column(String(100), nullable=True)
md_price = Column(BigInteger, nullable=True) md_price = Column(BigInteger, nullable=True)
supplier_type = Column(SmallInteger, nullable=True)
iteration = Column(Integer, nullable=False, default=0) iteration = Column(Integer, nullable=False, default=0)
preferred_sp_yn = Column(Boolean, nullable=True) preferred_sp_yn = Column(Boolean, nullable=True)
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) preferred_sp_id = Column(UUID(as_uuid=True), nullable=True)

View File

@ -237,8 +237,8 @@ class CardType(CodeEnum):
class SupplierType(CodeEnum): class SupplierType(CodeEnum):
"""quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). """partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0) 매핑 0없음 대응.""" 없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다."""
NONE = 0 # 없음(미지정) NONE = 0 # 없음(미지정)
DISTRIBUTION = 1 # 유통 DISTRIBUTION = 1 # 유통

View File

@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import ( from common.database.model.models import (
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings, 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.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG from common.logger import LOG
@ -44,7 +44,7 @@ class IQuotationCRUD(ABC):
pass pass
@abstractmethod @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 pass
@abstractmethod @abstractmethod
@ -400,33 +400,30 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {} return ErrorType.DB_RUN_FAILED, {}
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]:
"""협력사의 직전 견적 supplier_type. (supplier_type, qt_number) | None. """(item_id, supplier_id) -> supplier_items.supply_type 매핑.
sessions(supplier_id) quotations 에서 supplier_type 있는 최신 견적 1."""
매핑이 없거나 supply_type 0/NULL 이면 호출측이 정적 앵커링 값으로 폴백한다.
"""
try: try:
conds = [ if not item_ids or not supplier_ids:
sessions.supplier_id == supplier_id, return ErrorType.SUCCESS, {}
quotations.supplier_type.isnot(None), query = select(
quotations.deleted == False, # noqa: E712 supplier_items.item_id,
] supplier_items.supplier_id,
if company_id is not None: supplier_items.supply_type,
conds.append(quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id))) ).where(
query = ( supplier_items.item_id.in_(item_ids),
select(quotations.supplier_type, quotations.number) supplier_items.supplier_id.in_(supplier_ids),
.join(sessions, sessions.quotation_id == quotations.qt_id) supplier_items.deleted == False, # noqa: E712
.where(*conds)
.order_by(quotations.created_at.desc())
.limit(1)
) )
err_type, rows = await DB_SESSION_MNG.execute(cdb, query) err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, None return err_type, {}
if not rows: return ErrorType.SUCCESS, {(r[0], r[1]): r[2] for r in rows}
return ErrorType.SUCCESS, None
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(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]: async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
"""견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수). """견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수).

View File

@ -4,7 +4,7 @@ from typing import Any, Optional
from pydantic import ConfigDict 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 from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -26,7 +26,6 @@ class Req_CreateQuotation(QuotationProtocol):
manager_contact_number: Optional[str] = None manager_contact_number: Optional[str] = None
memo: Optional[str] = None memo: Optional[str] = None
md_price: Optional[int] = None # MD 제시가(원). 세션 목표가 산정 최우선값 md_price: Optional[int] = None # MD 제시가(원). 세션 목표가 산정 최우선값
supplier_type: Optional[int] = None # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성 item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사 supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결 card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결
@ -63,7 +62,6 @@ class QuotationData(WebPacketProtocol):
manager_contact_number: Optional[str] = None manager_contact_number: Optional[str] = None
memo: Optional[str] = None memo: Optional[str] = None
md_price: Optional[int] = None md_price: Optional[int] = None
supplier_type: Optional[SupplierType] = None
iteration: int = 0 iteration: int = 0
preferred_sp_yn: Optional[bool] = None preferred_sp_yn: Optional[bool] = None
preferred_sp_id: Optional[uuid.UUID] = None preferred_sp_id: Optional[uuid.UUID] = None
@ -189,11 +187,6 @@ class Res_QuotationCards(Res_WebPacketProtocol):
cards: list[QuotationCardData] = [] cards: list[QuotationCardData] = []
class Res_LastSupplierType(Res_WebPacketProtocol):
supplier_type: Optional[SupplierType] = None # 협력사 직전 견적의 유형(없으면 None)
qt_number: Optional[str] = None # 그 견적의 번호(이전 견적 값임을 표시용)
class TargetCandidate(WebPacketProtocol): class TargetCandidate(WebPacketProtocol):
basis: str basis: str
label: str label: str

View File

@ -12,7 +12,6 @@ from .protocol import (
Req_RegenerateQuotation, Req_RegenerateQuotation,
Res_CreateQuotation, Res_CreateQuotation,
Res_DeleteQuotation, Res_DeleteQuotation,
Res_LastSupplierType,
Res_NotifySessions, Res_NotifySessions,
Res_Quotation, Res_Quotation,
Res_QuotationCards, 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)) 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="견적 조회") @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)): async def get_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):

View File

@ -34,7 +34,7 @@ INSERT INTO quotation.quotations
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, (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, 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, 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 VALUES
-- ① 낙찰(절감): 목표 400,000 → 낙찰 372,000 -- ① 낙찰(절감): 목표 400,000 → 낙찰 372,000
('aaaa0001-0000-0000-0000-000000000001','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0001-0000-0000-0000-000000000001','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
@ -42,14 +42,14 @@ VALUES
'① 낙찰(절감) 데모','EST-DEMO-01',4,1,3, '① 낙찰(절감) 데모','EST-DEMO-01',4,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰·목표대비 절감 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰·목표대비 절감 케이스',
1, true,'9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','(주)대한정밀', null, null, 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=낙찰) -- ② 낙찰(목표초과): 목표 350,000 → 낙찰 368,000 (over_action=낙찰)
('aaaa0002-0000-0000-0000-000000000002','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0002-0000-0000-0000-000000000002','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'② 낙찰(목표초과) 데모','EST-DEMO-02',4,1,3, '② 낙찰(목표초과) 데모','EST-DEMO-02',4,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰이지만 목표가 초과 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰이지만 목표가 초과 케이스',
1, true,'69e7057a-5919-48cd-b329-13b833bff994','서울산업소재(주)', null, null, 1, true,'69e7057a-5919-48cd-b329-13b833bff994','서울산업소재(주)', null, null,
1, 350000, 1, now(), now(), false), 1, 350000, now(), now(), false),
-- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행 -- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행
('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','동가 발생·다음 라운드 재입찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가 발생·다음 라운드 재입찰 케이스',
1, false, null, null, true, 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, '[{"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): 협상거부로 마감 -- ④ 거부 유찰(FAIL_REJECT): 협상거부로 마감
('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'④ 거부 개찰 데모','EST-DEMO-04',2,1,3, '④ 거부 개찰 데모','EST-DEMO-04',2,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스',
1, null, null, null, null, null, 1, null, null, null, null, null,
8, 400000, 1, now(), now(), false), 8, 400000, now(), now(), false),
-- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집 -- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집
('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'⑤ 미응찰 개찰 데모','EST-DEMO-05',1,1,3, '⑤ 미응찰 개찰 데모','EST-DEMO-05',1,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스',
1, false, null, null, false, null, 1, false, null, null, false, null,
7, 400000, 1, now(), now(), false), 7, 400000, now(), now(), false),
-- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL) -- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL)
('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'⑥ 진행중 데모','EST-DEMO-06',4,1,2, '⑥ 진행중 데모','EST-DEMO-06',4,1,2,
now()-interval '1 day', now()+interval '1 day','김담당','mgr@imk.kr','010-1111-1111','협상 진행중·현재 최저 투찰 케이스', now()-interval '1 day', now()+interval '1 day','김담당','mgr@imk.kr','010-1111-1111','협상 진행중·현재 최저 투찰 케이스',
1, null, null, null, null, null, 1, null, null, null, null, null,
null, 400000, 1, now(), now(), false), null, 400000, now(), now(), false),
-- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행 -- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행
('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'⑦ 목표초과 개찰 데모','EST-DEMO-07',4,1,3, '⑦ 목표초과 개찰 데모','EST-DEMO-07',4,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스',
1, false, null, null, false, null, 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=유찰(또는 한도소진) → 유찰 -- ⑧ 가격 유찰(FAIL_PRICE): 단독 최저가 430,000 > 목표 350,000, over_action=유찰(또는 한도소진) → 유찰
('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'⑧ 목표초과 개찰 데모(2)','EST-DEMO-08',4,1,3, '⑧ 목표초과 개찰 데모(2)','EST-DEMO-08',4,1,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스',
1, false, null, null, false, null, 1, false, null, null, false, null,
5, 350000, 1, now(), now(), false), 5, 350000, now(), now(), false),
-- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰 -- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰
('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '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','동가지만 재입찰 한도 소진 → 유찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가지만 재입찰 한도 소진 → 유찰 케이스',
1, false, null, null, true, 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, '[{"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): 전원 미참여 + 재소집 한도 소진 → 유찰 -- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰
('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba', ('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030', '40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
'⑩ 미응찰 개찰 데모(2)','EST-DEMO-10',1,2,3, '⑩ 미응찰 개찰 데모(2)','EST-DEMO-10',1,2,3,
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스', now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스',
1, false, null, null, false, null, 1, false, null, null, false, null,
7, 400000, 1, now(), now(), false); 7, 400000, now(), now(), false);
-- ── 세션 ──────────────────────────────────────────────────────────────── -- ── 세션 ────────────────────────────────────────────────────────────────
-- 공통: item=a3d1fff5, DeliveryType 협력사배송=1 -- 공통: item=a3d1fff5, DeliveryType 협력사배송=1

View File

@ -29,7 +29,6 @@ from router.v1.quotation.protocol import (
Req_CreateQuotation, Req_CreateQuotation,
Res_CreateQuotation, Res_CreateQuotation,
Res_DeleteQuotation, Res_DeleteQuotation,
Res_LastSupplierType,
Res_NotifySessions, Res_NotifySessions,
Res_Quotation, Res_Quotation,
Res_QuotationCards, Res_QuotationCards,
@ -255,22 +254,6 @@ class QuotationService:
res.quotation = QuotationData.model_validate(quotation) res.quotation = QuotationData.model_validate(quotation)
return res 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: async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다. """[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다.
생성 성공 작성자에게 CREATED 알림(인박스).""" 생성 성공 작성자에게 CREATED 알림(인박스)."""
@ -291,7 +274,6 @@ class QuotationService:
manager_contact_number=req.manager_contact_number, manager_contact_number=req.manager_contact_number,
memo=req.memo, memo=req.memo,
md_price=req.md_price, md_price=req.md_price,
supplier_type=req.supplier_type,
item_ids=req.item_ids, item_ids=req.item_ids,
supplier_ids=req.supplier_ids, supplier_ids=req.supplier_ids,
card_ids=req.card_ids, card_ids=req.card_ids,
@ -370,7 +352,6 @@ class QuotationService:
manager_contact_number=original.manager_contact_number, manager_contact_number=original.manager_contact_number,
memo=original.memo, memo=original.memo,
md_price=original.md_price, md_price=original.md_price,
supplier_type=original.supplier_type,
item_ids=item_ids, item_ids=item_ids,
supplier_ids=list(supplier_ids), supplier_ids=list(supplier_ids),
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용) card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
@ -383,7 +364,7 @@ class QuotationService:
self, *, self, *,
user_id: str, qt_setting_id, version_id, name: str, number: str, user_id: str, qt_setting_id, version_id, name: str, number: str,
type_: int, status: int, round_: int, start_time, end_time, 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, item_ids: list, supplier_ids: list, card_ids: list,
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN) mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN) over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
@ -418,7 +399,7 @@ class QuotationService:
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
# 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) — # 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) —
# 칸(회사×협력사유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②. # 칸(회사×상품-협력사 공급유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②.
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다. # 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.) # (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
@ -465,7 +446,6 @@ class QuotationService:
manager_contact_number=manager_contact_number, manager_contact_number=manager_contact_number,
memo=memo, memo=memo,
md_price=md_price, md_price=md_price,
supplier_type=supplier_type,
mid_action=mid_action, mid_action=mid_action,
over_action=over_action, over_action=over_action,
) )
@ -491,8 +471,8 @@ class QuotationService:
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE) res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
return res return res
# ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) anchoring_value 조회 후 # ② 앵커가 산출 — 칸(items.company_id × supplier_items.supply_type × 목표가 구간) anchoring_value 조회 후
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는 # 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 매핑 미지정/조정 이력 없음/조회 실패는
# 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 6). # 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
_err, item_companies = await DB_SESSION_MNG.execute_lambda( _err, item_companies = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), quotations.DBType(),
@ -500,12 +480,19 @@ class QuotationService:
lambda s: self.quotation_crud.get_item_companies(s, item_ids), lambda s: self.quotation_crud.get_item_companies(s, item_ids),
) )
item_companies = item_companies if _err == ErrorType.SUCCESS else {} 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 = {} 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( value_map = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), quotations.DBType(),
DBWRType.DB_READ.value, 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 = [] session_objs = []
@ -513,11 +500,12 @@ class QuotationService:
tp = target_prices[iid] tp = target_prices[iid]
price_range = calc_price_range_index(tp) price_range = calc_price_range_index(tp)
company = item_companies.get(iid) 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) # 목표가×(1000value)//1000 — float 곱셈 금지(1원 내림 정확성)
for sid in supplier_ids: 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) # 목표가×(1000value)//1000 — float 곱셈 금지(1원 내림 정확성)
session_objs.append( session_objs.append(
sessions( sessions(
session_id=uuid.uuid4(), session_id=uuid.uuid4(),

View File

@ -1,8 +1,8 @@
"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증. """앵커링 v1.2 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
이식 명세: schedules/anchoring/docs/인수인계.md §1. 이식 명세: schedules/anchoring/docs/인수인계.md §1.
- 앵커가 = 목표가 × (1000 anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제 - 앵커가 = 목표가 × (1000 anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제
- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 정적 테이블 시작값(10) 폴백, - 조정 이력 없음 / 매핑 유형 미지정 / anchoring 스키마 미적용 정적 테이블 시작값(10) 폴백,
견적 생성은 실패하지 않는다(규칙 6) 견적 생성은 실패하지 않는다(규칙 6)
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 재계산(규칙 1 상속 폐지) - 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 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): 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 박제.""" 기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990(시작값), anchoring_value=10 박제."""
await _drop_anchoring(db_engine) await _drop_anchoring(db_engine)
item = await _seed_item(db_engine, company_id, internet_lowest=100_000) 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 assert res.result.success is True
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200 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)) 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) 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 assert res.result.success is True
rows = await _session_anchor_rows(db_engine, res.qt_id) 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) 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): async def test_supply_type_unset_uses_base_value(db_engine, company_id):
"""검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가. """검증: supply_type 미지정(None) 매핑으로 견적 생성 — 칸(회사×유형×구간) 구성 불가.
기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10 박제.""" 기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10 박제."""
await _reset_anchoring(db_engine) await _reset_anchoring(db_engine)
item = await _seed_item(db_engine, company_id, internet_lowest=100_000) item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) 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) 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 assert res.result.success is True
rows = await _session_anchor_rows(db_engine, res.qt_id) 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) item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
supplier = uuid.uuid4() 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 assert res1.result.success is True
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
rows1 = await _session_anchor_rows(db_engine, res1.qt_id) rows1 = await _session_anchor_rows(db_engine, res1.qt_id)
@ -108,20 +108,40 @@ def _service():
return QuotationService(QuotationCRUD()) return QuotationService(QuotationCRUD())
async def _create(*, item_ids, supplier_type, supplier_ids=None): async def _create(engine, *, item_ids, supply_type, supplier_ids=None):
"""supplier_type 을 지정해 견적 1건 생성(공급사 기본 1곳).""" """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( req = Req_CreateQuotation(
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(앵커는 세팅과 무관해짐) qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(앵커는 세팅과 무관해짐)
name="앵커링검증", name="앵커링검증",
type=QuotationType.NEW_QUOTE.value, type=QuotationType.NEW_QUOTE.value,
end_time=FUTURE, end_time=FUTURE,
supplier_type=supplier_type,
item_ids=list(item_ids), 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) 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): async def _seed_item(engine, company_id, *, internet_lowest):
"""상품 1건 시드(인터넷최저가만). NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음).""" """상품 1건 시드(인터넷최저가만). NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음)."""
item_id = uuid.uuid4() item_id = uuid.uuid4()

View File

@ -114,7 +114,6 @@ export * from './quotationDataOverAction';
export * from './quotationDataPreferredSpId'; export * from './quotationDataPreferredSpId';
export * from './quotationDataPreferredSpName'; export * from './quotationDataPreferredSpName';
export * from './quotationDataPreferredSpYn'; export * from './quotationDataPreferredSpYn';
export * from './quotationDataSupplierType';
export * from './quotationDataUpdatedAt'; export * from './quotationDataUpdatedAt';
export * from './quotationSettingData'; export * from './quotationSettingData';
export * from './quotationSettingDataCreatedAt'; export * from './quotationSettingDataCreatedAt';
@ -161,7 +160,6 @@ export * from './reqCreateQuotationMidAction';
export * from './reqCreateQuotationOverAction'; export * from './reqCreateQuotationOverAction';
export * from './reqCreateQuotationSetting'; export * from './reqCreateQuotationSetting';
export * from './reqCreateQuotationStartTime'; export * from './reqCreateQuotationStartTime';
export * from './reqCreateQuotationSupplierType';
export * from './reqCreateQuotationVersionId'; export * from './reqCreateQuotationVersionId';
export * from './reqCreateSupplier'; export * from './reqCreateSupplier';
export * from './reqCreateSupplierCode'; export * from './reqCreateSupplierCode';
@ -269,10 +267,6 @@ export * from './resItemListMsg';
export * from './resItemMsg'; export * from './resItemMsg';
export * from './resItemSupplyTypeList'; export * from './resItemSupplyTypeList';
export * from './resItemSupplyTypeListMsg'; export * from './resItemSupplyTypeListMsg';
export * from './resLastSupplierType';
export * from './resLastSupplierTypeMsg';
export * from './resLastSupplierTypeQtNumber';
export * from './resLastSupplierTypeSupplierType';
export * from './resLogin'; export * from './resLogin';
export * from './resLoginMsg'; export * from './resLoginMsg';
export * from './resLowestPriceResult'; export * from './resLowestPriceResult';
@ -380,4 +374,4 @@ export * from './userRole';
export * from './userStatus'; export * from './userStatus';
export * from './validationError'; export * from './validationError';
export * from './validationErrorCtx'; export * from './validationErrorCtx';
export * from './validationErrorLocItem'; export * from './validationErrorLocItem';

View File

@ -11,7 +11,6 @@ import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber'; import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
import type { QuotationDataMemo } from './quotationDataMemo'; import type { QuotationDataMemo } from './quotationDataMemo';
import type { QuotationDataMdPrice } from './quotationDataMdPrice'; import type { QuotationDataMdPrice } from './quotationDataMdPrice';
import type { QuotationDataSupplierType } from './quotationDataSupplierType';
import type { QuotationDataPreferredSpYn } from './quotationDataPreferredSpYn'; import type { QuotationDataPreferredSpYn } from './quotationDataPreferredSpYn';
import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId'; import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId';
import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName'; import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName';
@ -43,7 +42,6 @@ export interface QuotationData {
manager_contact_number?: QuotationDataManagerContactNumber; manager_contact_number?: QuotationDataManagerContactNumber;
memo?: QuotationDataMemo; memo?: QuotationDataMemo;
md_price?: QuotationDataMdPrice; md_price?: QuotationDataMdPrice;
supplier_type?: QuotationDataSupplierType;
iteration?: number; iteration?: number;
preferred_sp_yn?: QuotationDataPreferredSpYn; preferred_sp_yn?: QuotationDataPreferredSpYn;
preferred_sp_id?: QuotationDataPreferredSpId; preferred_sp_id?: QuotationDataPreferredSpId;

View File

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

View File

@ -11,7 +11,6 @@ import type { ReqCreateQuotationManagerEmail } from './reqCreateQuotationManager
import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber'; import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber';
import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo'; import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice'; import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice';
import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType';
import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction'; import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction';
import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction'; import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction';
@ -29,7 +28,6 @@ export interface ReqCreateQuotation {
manager_contact_number?: ReqCreateQuotationManagerContactNumber; manager_contact_number?: ReqCreateQuotationManagerContactNumber;
memo?: ReqCreateQuotationMemo; memo?: ReqCreateQuotationMemo;
md_price?: ReqCreateQuotationMdPrice; md_price?: ReqCreateQuotationMdPrice;
supplier_type?: ReqCreateQuotationSupplierType;
item_ids?: string[]; item_ids?: string[];
supplier_ids?: string[]; supplier_ids?: string[];
card_ids?: string[]; card_ids?: string[];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,8 +6,8 @@
*/ */
/** /**
* quotations.supplier_type . (0,)/(1)/(2)/(3). * partner.supplier_items.supply_type . (0,)/(1)/(2)/(3).
'없음' . KTC (=0) 0 . - .
*/ */
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]; export type SupplierType = typeof SupplierType[keyof typeof SupplierType];

View File

@ -31,7 +31,6 @@ import type {
ReqRegenerateQuotation, ReqRegenerateQuotation,
ResCreateQuotation, ResCreateQuotation,
ResDeleteQuotation, ResDeleteQuotation,
ResLastSupplierType,
ResNotifySessions, ResNotifySessions,
ResQuotation, ResQuotation,
ResQuotationCards, ResQuotationCards,
@ -1136,96 +1135,6 @@ export const useDeleteQuotation = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); 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>>

View File

@ -13,7 +13,6 @@ type TargetPriceModalProps = {
vatYn?: boolean | null; vatYn?: boolean | null;
deliveryFeeYn?: boolean | null; deliveryFeeYn?: boolean | null;
category?: string | null; category?: string | null;
supplierTypeLabel: string;
}; };
const won = (n?: number | null) => (n != null ? `${n.toLocaleString()}` : '-'); const won = (n?: number | null) => (n != null ? `${n.toLocaleString()}` : '-');
@ -32,7 +31,6 @@ export function TargetPriceModal({
vatYn, vatYn,
deliveryFeeYn, deliveryFeeYn,
category, category,
supplierTypeLabel,
}: TargetPriceModalProps) { }: TargetPriceModalProps) {
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금 useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } }); const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
@ -130,7 +128,6 @@ export function TargetPriceModal({
{category && ( {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"> : {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] text-muted-foreground"> : {bd.anchoring_value}</Typography>
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground"> <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> : {won(bd.anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= ×(1{bd.anchoring_value})</span>

View File

@ -21,7 +21,6 @@ import {
mapServerCardView, mapServerCardView,
chainRoundState, chainRoundState,
} from '../../types'; } from '../../types';
import { supplierTypeLabel } from '@/lib/enumLabels';
import { QuotationStatus } from '@/api/generated/model'; import { QuotationStatus } from '@/api/generated/model';
import { DrawerHeaderCards } from './DrawerHeaderCards'; import { DrawerHeaderCards } from './DrawerHeaderCards';
import { RoundTimeline } from './RoundTimeline'; import { RoundTimeline } from './RoundTimeline';
@ -314,7 +313,6 @@ export function QuotationDetailSheet({
vatYn={currentItem.vat_yn} vatYn={currentItem.vat_yn}
deliveryFeeYn={currentItem.delivery_fee_yn} deliveryFeeYn={currentItem.delivery_fee_yn}
category={currentItem.category} category={currentItem.category}
supplierTypeLabel={supplierTypeLabel(quotation.supplier_type)}
/> />
); );
})()} })()}

View File

@ -105,7 +105,7 @@
### SupplierType — 협력사(공급채널) 유형 ### 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 | 제조 | | 2 | MANUFACTURE | 제조 |
| 3 | SOLE_AGENCY | 총판 | | 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 — 알림 유형 ### NotificationType — 알림 유형

View File

@ -291,7 +291,6 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처 manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처
memo VARCHAR(100) NULL, -- 메모 memo VARCHAR(100) NULL, -- 메모
md_price BIGINT NULL, -- MD 제시가(원). 목표가 산정 최우선값 (견적생성 모달 입력) 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, -- 반복 횟수 iteration INTEGER NOT NULL DEFAULT 0, -- 반복 횟수
preferred_sp_yn BOOLEAN NULL, -- 선호 공급사 지정 여부 preferred_sp_yn BOOLEAN NULL, -- 선호 공급사 지정 여부
preferred_sp_id uuid NULL, -- 선호 공급사(partner.suppliers.supplier_id) 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. -- 멱등 ALTER 로 여기에 함께 둔다. 신규 DB 에는 전부 no-op.
-- 새 스키마 변경 시 위 테이블 정의와 이 섹션을 동시에 갱신한다 (구 04-alter*.sql 의 역할). -- 새 스키마 변경 시 위 테이블 정의와 이 섹션을 동시에 갱신한다 (구 04-alter*.sql 의 역할).
-- 기준선: 2026-07-07 main 스키마. 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용. -- 기준선: 2026-07-07 main 스키마. 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
ALTER TABLE quotation.quotations DROP COLUMN IF EXISTS supplier_type;

View File

@ -24,7 +24,7 @@ from anchoring.constants import (
) )
from anchoring.db import session_scope from anchoring.db import session_scope
from anchoring.log import LOG 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.reader import get_latest_adjusted_value
from anchoring.redis_client import consume_failure_counts, ping, set_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 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: 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)으로 정리된다(철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지). 제외 마킹(0)으로 정리된다(철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지).
company_ids: 대상 회사 한정(테스트·표적 수동 실행용). None = 전체. company_ids: 대상 회사 한정(테스트·표적 수동 실행용). None = 전체.
""" """
@ -91,7 +91,7 @@ async def _scan_pending(db, company_ids: list | None = None) -> list:
Session.anchoring_price, Session.anchoring_price,
Session.anchoring_value, Session.anchoring_value,
Session.last_offer_price, Session.last_offer_price,
Quotation.supplier_type, SupplierItem.supply_type.label("supplier_type"),
Item.company_id, Item.company_id,
) )
.join( .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), (Item.item_id == Session.item_id) & Item.deleted.is_(False),
isouter=True, isouter=True,
) )
.join(
SupplierItem,
(SupplierItem.item_id == Session.item_id)
& (SupplierItem.supplier_id == Session.supplier_id)
& SupplierItem.deleted.is_(False),
isouter=True,
)
.where( .where(
Session.used_by_adjustment_id.is_(None), Session.used_by_adjustment_id.is_(None),
Session.deleted.is_(False), Session.deleted.is_(False),

View File

@ -1,7 +1,7 @@
"""앵커링 도메인 상수 + 코드값(enum). 규범: docs/개발용.md §3. """앵커링 도메인 상수 + 코드값(enum). 규범: docs/개발용.md §3.
backend import 하지 않고 자체 보유한다(자립 모듈). 코드값은 프로젝트 컨벤션 backend import 하지 않고 자체 보유한다(자립 모듈). 코드값은 프로젝트 컨벤션
(SMALLINT 1-based + enum 매핑) 따르며 quotations.supplier_type 동일 코드다. (SMALLINT 1-based + enum 매핑) 따르며 supplier_items.supply_type 동일 코드다.
상수 변경은 정책 재확정 사안 코드에서 임의 조정 금지(§12). 상수 변경은 정책 재확정 사안 코드에서 임의 조정 금지(§12).
""" """
from enum import Enum from enum import Enum
@ -11,7 +11,7 @@ ANCHORING_VALUE_MIN = 10 # 하한 1%
ANCHORING_VALUE_MAX = 200 # 상한 20% ANCHORING_VALUE_MAX = 200 # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2) # 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2)
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드 # 유형별 조정폭 (올림·내림 대칭). 키 = supplier_items.supply_type SMALLINT 코드
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름) # ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름)
ADJUSTMENT_STEP = { ADJUSTMENT_STEP = {
1: 20, # 유통(DISTRIBUTION) ±2% 1: 20, # 유통(DISTRIBUTION) ±2%
@ -50,7 +50,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
class SupplierType(Enum): class SupplierType(Enum):
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type """협력사 유형 코드. partner.supplier_items.supply_type / anchoring.adjustments.supplier_type
(negodata SupplierType 동일 코드)""" (negodata SupplierType 동일 코드)"""
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외) NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
DISTRIBUTION = 1 # 유통 DISTRIBUTION = 1 # 유통

View File

@ -2,7 +2,7 @@
- 소유(쓰기): anchoring.adjustments (append-only UPDATE/DELETE 금지 §5) - 소유(쓰기): anchoring.adjustments (append-only UPDATE/DELETE 금지 §5)
- sessions used_by_adjustment_id 마킹만 쓰기 가능( 컬럼 수정 금지 §12). - sessions used_by_adjustment_id 마킹만 쓰기 가능( 컬럼 수정 금지 §12).
quotations/items 읽기 전용 경량 매핑(집계에 필요한 컬럼만). quotations/items/supplier_items 읽기 전용 경량 매핑(집계에 필요한 컬럼만).
""" """
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
@ -35,6 +35,7 @@ class Session(BASE):
session_id = Column(UUID(as_uuid=True), primary_key=True) session_id = Column(UUID(as_uuid=True), primary_key=True)
quotation_id = Column(UUID(as_uuid=True), nullable=False) quotation_id = Column(UUID(as_uuid=True), nullable=False)
item_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=재협상 qt_type = Column(SmallInteger, nullable=False) # 1=재협상
target_price = Column(BigInteger, nullable=False) target_price = Column(BigInteger, nullable=False)
anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준) anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준)
@ -51,7 +52,6 @@ class Quotation(BASE):
__table_args__ = {"schema": "quotation"} __table_args__ = {"schema": "quotation"}
qt_id = Column(UUID(as_uuid=True), primary_key=True) qt_id = Column(UUID(as_uuid=True), primary_key=True)
supplier_type = Column(SmallInteger, nullable=True) # NULL 이면 칸 구성 불가 → 제외
deleted = Column(Boolean, nullable=False) deleted = Column(Boolean, nullable=False)
@ -62,3 +62,14 @@ class Item(BASE):
item_id = Column(UUID(as_uuid=True), primary_key=True) item_id = Column(UUID(as_uuid=True), primary_key=True)
company_id = Column(UUID(as_uuid=True), nullable=True) # 테넌트(갑) — NULL 이면 칸 구성 불가 company_id = Column(UUID(as_uuid=True), nullable=True) # 테넌트(갑) — NULL 이면 칸 구성 불가
deleted = Column(Boolean, nullable=False) 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)

View File

@ -100,13 +100,18 @@ class Seeder:
await db.execute(text( await db.execute(text(
"INSERT INTO quotation.quotations " "INSERT INTO quotation.quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, " "(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, "
" start_time, end_time, supplier_type) " " start_time, end_time) "
"VALUES (:qid, :uid, :sid, :vid, 'anchoring-it-test', :num, :qtype, 1, 3, now(), now(), :stype)" "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(), "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() 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( await db.execute(text(
"INSERT INTO negotiation.sessions " "INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " "(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) " " status, bid_price, end_time) "
"VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :value, :lop, :status, :bid, now())" "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, "qtype": qt_type, "tp": target_price, "ap": anchoring_price, "value": anchoring_value,
"lop": last_offer_price, "status": status, "bid": bid_price, "lop": last_offer_price, "status": status, "bid": bid_price,
}) })