From a2c299aa14dcf98bf19b1f35343d72bfb71fb872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Mon, 6 Jul 2026 11:17:01 +0900 Subject: [PATCH] =?UTF-8?q?refactor(anchoring):=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=A6=84=20=EC=A0=84=EB=A9=B4=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=20=E2=80=94=20adjustments=C2=B7anchoring=5Fvalue/pric?= =?UTF-8?q?e=C2=B7price=5Frange=C2=B7sample=20=EC=9A=A9=EC=96=B4=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 용어 체계: 값=anchoring_value(정수‰)·가격=anchoring_price·조정=adjustment·구간=price_range·표본=sample - DB: rate_adjustments→anchoring.adjustments (id→adjustment_id, price_bracket_index→price_range_index, nego_count→sample_count, anchor_rate_before/after→anchoring_value_before/after, consumed_session_ids→used_session_ids) - sessions: target_anchoring_price→anchoring_price, anchor_rate_permille→anchoring_value, last_offered_price→last_offer_price, anchoring_adjustment_id→used_by_adjustment_id - 뷰: rate_history/current_rates→value_history/current_values, delta_permille→value_change - 코드: calc_price_range_index·calc_anchoring_price·evaluate_samples·get_current_value· get_latest_adjusted_value·get_current_anchoring_value·fetch_current_values·get_base_anchoring_value· Adjustment(ORM)·update_last_offer_price, 상수 ANCHORING_VALUE_MIN/MAX·ADJUSTMENT_STEP· PRICE_RANGE_COUNT/INDEX_MAX, 배치 로그 키 bracket=→price_range= - API: negodata protocol 필드 target_anchoring_price→anchoring_price (front 생성 모델·컴포넌트 동반) - 기존 DB 마이그레이션 신설: schedules/anchoring/migrations/20260706_rename_anchoring.sql (멱등 DO 블록 — 테이블·컬럼·뷰·인덱스·PK 제약. 코드 배포와 동시 적용 필요) - postgres-init 01·04, 문서 6종 동기화 - 실배포 전 수정 포함: main.py argparse 화(--dry-run 단독·오타 플래그 기동 전 차단), 박제 정합식 calc_anchoring_price 재사용, clamped 지표가 실제 포화만 집계(경계값 유지 제외) 주의: sessions.anchoring_value(정수‰)와 quotation_settings.anchoring_value(구 float 비율)는 같은 이름·다른 단위 — 구 컬럼은 미변경. 검증: 모듈 20·negodata 50·backend 57 테스트 통과, front tsc·vite build 통과, 로컬 DB 마이그레이션 적용 후 배치 dry-run·상주 기동·양 서버 부팅 확인. Co-Authored-By: Claude Fable 5 --- backend/common/database/model/models.py | 10 +- backend/crud/chat_crud.py | 6 +- backend/services/agent_client.py | 2 +- backend/services/chat_service.py | 10 +- backend/tests/test_anchoring_chat.py | 20 +- negodata/backend/common/anchoring/__init__.py | 14 +- .../backend/common/anchoring/base_table.py | 12 +- .../backend/common/anchoring/constants.py | 8 +- negodata/backend/common/anchoring/reader.py | 36 +-- negodata/backend/common/anchoring/service.py | 10 +- .../backend/common/database/model/models.py | 4 +- negodata/backend/crud/quotation_crud.py | 4 +- .../backend/router/v1/quotation/protocol.py | 4 +- .../backend/scripts/seed_demo_quotations.sql | 2 +- .../backend/services/quotation_service.py | 42 +-- .../backend/tests/test_quotation_anchoring.py | 112 ++++---- .../front/src/api/generated/model/index.ts | 4 +- .../api/generated/model/resTargetBreakdown.ts | 4 +- ...ts => resTargetBreakdownAnchoringPrice.ts} | 2 +- .../src/api/generated/model/sessionData.ts | 4 +- ...gPrice.ts => sessionDataAnchoringPrice.ts} | 2 +- .../DrawerHeaderCards.tsx | 2 +- .../SessionsStatusTab.tsx | 6 +- .../QuotationDetailSheet/TargetPriceModal.tsx | 2 +- .../front/src/features/quotations/types.ts | 4 +- postgres-init/01-schema_20260702.sql | 8 +- postgres-init/04-alter_20260702.sql | 10 +- schedules/anchoring/README.md | 15 +- schedules/anchoring/TODO.md | 10 +- schedules/anchoring/docs/개발용.md | 242 +++++++++--------- schedules/anchoring/docs/운영및유지보수.md | 35 +-- schedules/anchoring/docs/워크플로우.md | 4 +- schedules/anchoring/docs/인수인계.md | 30 +-- .../migrations/20260706_rename_anchoring.sql | 112 ++++++++ schedules/anchoring/schema.sql | 75 +++--- .../anchoring/src/anchoring/base_table.py | 26 +- schedules/anchoring/src/anchoring/batch.py | 131 +++++----- .../anchoring/src/anchoring/constants.py | 14 +- schedules/anchoring/src/anchoring/main.py | 31 ++- schedules/anchoring/src/anchoring/models.py | 28 +- schedules/anchoring/src/anchoring/reader.py | 40 +-- .../anchoring/src/anchoring/redis_client.py | 28 +- schedules/anchoring/src/anchoring/service.py | 55 ++-- schedules/anchoring/tests/conftest.py | 20 +- schedules/anchoring/tests/test_batch.py | 66 ++--- schedules/anchoring/tests/test_core.py | 97 +++---- 46 files changed, 775 insertions(+), 628 deletions(-) rename negodata/front/src/api/generated/model/{resTargetBreakdownTargetAnchoringPrice.ts => resTargetBreakdownAnchoringPrice.ts} (64%) rename negodata/front/src/api/generated/model/{sessionDataTargetAnchoringPrice.ts => sessionDataAnchoringPrice.ts} (66%) create mode 100644 schedules/anchoring/migrations/20260706_rename_anchoring.sql diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index befef90..bca3a1a 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -111,10 +111,10 @@ class sessions(MAIN_BASE): qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType) target_price = Column(BigInteger, nullable=False) # 목표가(원) - target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지 - anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지 - last_offered_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적" - anchoring_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용 + anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지 + anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지 + last_offer_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적" + used_by_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용 status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드) bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 @@ -165,7 +165,7 @@ class quotations(MAIN_BASE): class quotation_settings(MAIN_BASE): - # quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.target_anchoring_price 박제값). + # quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값). @staticmethod def DBType(): return DBType.QUOTATION.value diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py index 4c8978b..05d8678 100644 --- a/backend/crud/chat_crud.py +++ b/backend/crud/chat_crud.py @@ -44,7 +44,7 @@ class IChatCRUD(ABC): pass @abstractmethod - async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: + async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: pass @@ -136,7 +136,7 @@ class ChatCRUD(IChatCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED - async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: + async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: """협력사 마지막 제시가 갱신 — 가격 입력 턴의 봇 메시지 저장과 같은 트랜잭션에서 호출. 진행 중엔 매 가격 입력마다 덮어쓰고 종료 후엔 불변. 앵커링 표본 판정에서 @@ -147,7 +147,7 @@ class ChatCRUD(IChatCRUD): query = ( update(sessions) .where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value) - .values(last_offered_price=price) + .values(last_offer_price=price) ) return await DB_SESSION_MNG.add(cdb, query) except Exception as ex: diff --git a/backend/services/agent_client.py b/backend/services/agent_client.py index 409e047..92d173c 100644 --- a/backend/services/agent_client.py +++ b/backend/services/agent_client.py @@ -43,7 +43,7 @@ class AgentChatContext: tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id rq_type: str = "재협상" # 재협상 | 재견적 target_price: int = 0 # 갑 목표 매입가(원) - anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.target_anchoring_price. + anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.anchoring_price. item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용. # 핸드오프 #4: agent 의 RL 상태(state) 계산 입력. # partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어 diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index cad5a00..9ebedc3 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -369,7 +369,7 @@ class ChatService: # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). if price is not None: - funcs.append(lambda s: self.chat_crud.update_last_offered_price(s, sess.session_id, price)) + funcs.append(lambda s: self.chat_crud.update_last_offer_price(s, sess.session_id, price)) new_status = sess.status if turn.chat_end: if turn.outcome == "success": @@ -419,7 +419,7 @@ class ChatService: LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험") rq_type = "재협상" if sess.qt_type == 1 else "재견적" target_price = int(sess.target_price or 0) - # 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변. + # 앵커가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변. anchor = await self._resolve_anchor_price(sess, target_price) # 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력. partner_count = await self._count_partners(sess) @@ -434,7 +434,7 @@ class ChatService: ) async def _resolve_anchor_price(self, sess, target_price: int) -> int: - """세션에 박제된 앵커가(target_anchoring_price — negodata 가 생성 시 기록)를 그대로 사용. + """세션에 박제된 앵커가(anchoring_price — negodata 가 생성 시 기록)를 그대로 사용. 박제값 사용이 정상 경로다: 협상 진행 중 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다 ("제안 당시 값" 판정의 전제 — schedules/anchoring/docs/개발용.md §9.2). backend 는 앵커를 계산하지 않는다. @@ -443,8 +443,8 @@ class ChatService: """ if not target_price: return 0 - if sess.target_anchoring_price is not None: - return int(sess.target_anchoring_price) + if sess.anchoring_price is not None: + return int(sess.anchoring_price) LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외") return target_price diff --git a/backend/tests/test_anchoring_chat.py b/backend/tests/test_anchoring_chat.py index a9ab911..7470b58 100644 --- a/backend/tests/test_anchoring_chat.py +++ b/backend/tests/test_anchoring_chat.py @@ -110,7 +110,7 @@ async def anchor_seed(db_engine): await conn.execute( text("INSERT INTO negotiation.sessions " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " - " target_price, target_anchoring_price, anchor_rate_permille, status, end_time) " + " target_price, anchoring_price, anchoring_value, status, end_time) " "VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, 1, :tp, :ap, :rate, 2, now() + interval '2 hours')"), {"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id, "qtn": f"{MARK}{code}", "tp": TARGET, "ap": anchor, "rate": rate}, @@ -143,7 +143,7 @@ async def _send(client, token, sid, user_input, user_input_type=None): async def _anchor_columns(db_engine, session_id): async with db_engine.begin() as conn: row = (await conn.execute(text( - "SELECT target_anchoring_price, anchor_rate_permille, last_offered_price, bid_price " + "SELECT anchoring_price, anchoring_value, last_offer_price, bid_price " "FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).one() return row @@ -158,21 +158,21 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch r = await _send(client, token, sid, "네, 시작할게요") # → 가격 입력 요청 (아직 가격 흔적 없음) assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시" row = await _anchor_columns(db_engine, sid) - assert row.last_offered_price is None + assert row.last_offer_price is None assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달 r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복 assert r.json()["message"]["step"] == "기존가격제시" row = await _anchor_columns(db_engine, sid) - assert row.last_offered_price == 99_500 # 가격 흔적 기록 - # 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offered_price 로 실패 표본이 된다. + assert row.last_offer_price == 99_500 # 가격 흔적 기록 + # 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offer_price 로 실패 표본이 된다. r = await _send(client, token, sid, "98,000", "price") # 앵커 이하 → 합의 종료 assert r.json()["session_status"] == 3 # DONE row = await _anchor_columns(db_engine, sid) - assert row.last_offered_price == 98_000 # 마지막 값으로 갱신 + assert row.last_offer_price == 98_000 # 마지막 값으로 갱신 assert row.bid_price == 98_000 - assert (row.target_anchoring_price, row.anchor_rate_permille) == (ANCHOR, 10) # 박제 불변 + assert (row.anchoring_price, row.anchoring_value) == (ANCHOR, 10) # 박제 불변 # ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ── @@ -188,6 +188,6 @@ async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료) assert r.json()["session_status"] == 3 row = await _anchor_columns(db_engine, sid) - assert row.target_anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외) - assert row.anchor_rate_permille is None - assert row.last_offered_price == 97_000 # 가격 흔적 기록은 정상 동작 + assert row.anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외) + assert row.anchoring_value is None + assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작 diff --git a/negodata/backend/common/anchoring/__init__.py b/negodata/backend/common/anchoring/__init__.py index 35efef4..43ae241 100644 --- a/negodata/backend/common/anchoring/__init__.py +++ b/negodata/backend/common/anchoring/__init__.py @@ -7,16 +7,16 @@ 값 조정(격주 배치)·표본 판정은 이식 대상이 아니다(schedules/anchoring 서비스 담당). 상수·계산식을 고칠 일이 생기면 원본 모듈과 반드시 함께 고친다(단독 수정 금지). """ -from common.anchoring.base_table import get_base_rate_permille, load_base_table +from common.anchoring.base_table import get_base_anchoring_value, load_base_table from common.anchoring.constants import SAMPLEABLE_SUPPLIER_TYPES -from common.anchoring.reader import fetch_current_rates -from common.anchoring.service import calc_anchor_price, calc_bracket_index +from common.anchoring.reader import fetch_current_values +from common.anchoring.service import calc_anchoring_price, calc_price_range_index __all__ = [ "SAMPLEABLE_SUPPLIER_TYPES", - "calc_anchor_price", - "calc_bracket_index", - "fetch_current_rates", - "get_base_rate_permille", + "calc_anchoring_price", + "calc_price_range_index", + "fetch_current_values", + "get_base_anchoring_value", "load_base_table", ] diff --git a/negodata/backend/common/anchoring/base_table.py b/negodata/backend/common/anchoring/base_table.py index 92c64d4..befe99a 100644 --- a/negodata/backend/common/anchoring/base_table.py +++ b/negodata/backend/common/anchoring/base_table.py @@ -7,11 +7,11 @@ DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증 import json from pathlib import Path -from common.anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS +from common.anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS _RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json" -_rates: list[int] | None = None # bracket_index → 시작값(‰) +_rates: list[int] | None = None # price_range_index → 시작값(‰) class BaseTableError(RuntimeError): @@ -23,8 +23,8 @@ def _validate(rows: list) -> list[int]: 규약: 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20. """ - if not isinstance(rows, list) or len(rows) != BRACKET_COUNT: - raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}") + if not isinstance(rows, list) or len(rows) != PRICE_RANGE_COUNT: + raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {PRICE_RANGE_COUNT}") rates: list[int] = [] for i, row in enumerate(rows): idx = row.get("idx") @@ -52,8 +52,8 @@ def load_base_table() -> None: _rates = _validate(rows) -def get_base_rate_permille(bracket_index: int) -> int: +def get_base_anchoring_value(price_range_index: int) -> int: """구간 인덱스 → 시작 앵커링 값(‰).""" if _rates is None: load_base_table() - return _rates[bracket_index] + return _rates[price_range_index] diff --git a/negodata/backend/common/anchoring/constants.py b/negodata/backend/common/anchoring/constants.py index 7e7b96e..0ca4e07 100644 --- a/negodata/backend/common/anchoring/constants.py +++ b/negodata/backend/common/anchoring/constants.py @@ -6,8 +6,8 @@ """ # ── 앵커링 값(정수 천분율 ‰) ────────────────────────────── -ANCHOR_RATE_MIN = 10 # 하한 1% -ANCHOR_RATE_MAX = 200 # 상한 20% +ANCHORING_VALUE_MIN = 10 # 하한 1% +ANCHORING_VALUE_MAX = 200 # 상한 20% # 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지 # ── 가격구간 (자릿수 계단식 사다리 — 폭 = 구간 상한의 10% = 선행 자릿수 밴드) ── @@ -25,8 +25,8 @@ def _build_upper_bounds() -> tuple: UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개 -BRACKET_COUNT = len(UPPER_BOUNDS) # 46 -BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45 +PRICE_RANGE_COUNT = len(UPPER_BOUNDS) # 46 +PRICE_RANGE_INDEX_MAX = PRICE_RANGE_COUNT - 1 # 45 # 칸을 구성할 수 있는 협력사 유형 코드 — common.enums.SupplierType 의 유통(1)/제조(2)/총판(3). # 이 외(NONE=0/NULL)는 칸 해석 불가 → 정적 테이블 시작값 사용(배치 집계에서도 자동 제외). diff --git a/negodata/backend/common/anchoring/reader.py b/negodata/backend/common/anchoring/reader.py index 5df3fc7..0f3d581 100644 --- a/negodata/backend/common/anchoring/reader.py +++ b/negodata/backend/common/anchoring/reader.py @@ -2,7 +2,7 @@ 원본 reader(schedules/anchoring)는 Redis 캐시를 먼저 보지만, 이식판은 DB 직조회 한 문장만 쓴다 (2026-07-03 단순화 결정 — 조회가 견적 생성 시에만 일어나 캐시가 불필요, Redis 의존 제거). -칸별 최신 조정 rate 는 모듈 소유 뷰 `anchoring.current_rates` 가 제공하고, +칸별 최신 조정 rate 는 모듈 소유 뷰 `anchoring.current_values` 가 제공하고, 조정 이력이 없는 칸은 결과에 없으므로 호출측이 정적 테이블 시작값으로 폴백한다. 견적 생성이 앵커 조회 때문에 실패해서는 안 된다(인수인계.md §1 규칙 6) — anchoring 스키마 @@ -11,22 +11,22 @@ from sqlalchemy import column, select, table from sqlalchemy.ext.asyncio import AsyncSession -from common.anchoring.constants import ANCHOR_RATE_MAX, ANCHOR_RATE_MIN, SAMPLEABLE_SUPPLIER_TYPES +from common.anchoring.constants import ANCHORING_VALUE_MAX, ANCHORING_VALUE_MIN, SAMPLEABLE_SUPPLIER_TYPES from common.logger import LOG # 모듈 소유 DDL(schedules/anchoring/schema.sql)의 조회용 뷰 — negodata 는 ORM 모델 없이 읽기만 한다. -_current_rates = table( - "current_rates", +_current_values = table( + "current_values", column("company_id"), - column("price_bracket_index"), - column("anchor_rate_permille"), + column("price_range_index"), + column("anchoring_value"), column("supplier_type"), schema="anchoring", ) -async def fetch_current_rates(db: AsyncSession, company_ids: list, supplier_type: int) -> dict: - """칸별 현재 앵커링 값 일괄 조회. {(company_id, bracket_index): rate‰} 반환. +async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_type: int) -> dict: + """칸별 현재 앵커링 값 일괄 조회. {(company_id, price_range_index): rate‰} 반환. supplier_type 은 견적 단위로 하나뿐이라 키에 넣지 않는다. 조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 시 빈 dict.""" @@ -34,22 +34,22 @@ async def fetch_current_rates(db: AsyncSession, company_ids: list, supplier_type return {} try: stmt = select( - _current_rates.c.company_id, - _current_rates.c.price_bracket_index, - _current_rates.c.anchor_rate_permille, + _current_values.c.company_id, + _current_values.c.price_range_index, + _current_values.c.anchoring_value, ).where( - _current_rates.c.company_id.in_(company_ids), - _current_rates.c.supplier_type == supplier_type, + _current_values.c.company_id.in_(company_ids), + _current_values.c.supplier_type == supplier_type, ) rows = (await db.execute(stmt)).all() except Exception as ex: - LOG.w(f"[앵커링] current_rates 조회 실패 — 전량 정적 테이블 폴백: {ex}") + LOG.w(f"[앵커링] current_values 조회 실패 — 전량 정적 테이블 폴백: {ex}") return {} out = {} - for company_id, bracket_index, rate in rows: - if not ANCHOR_RATE_MIN <= rate <= ANCHOR_RATE_MAX: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백 - LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} bracket={bracket_index} rate={rate}") + for company_id, 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}") continue - out[(company_id, bracket_index)] = rate + out[(company_id, price_range_index)] = rate return out diff --git a/negodata/backend/common/anchoring/service.py b/negodata/backend/common/anchoring/service.py index b5ddae5..58886ba 100644 --- a/negodata/backend/common/anchoring/service.py +++ b/negodata/backend/common/anchoring/service.py @@ -5,17 +5,17 @@ """ from bisect import bisect_right -from common.anchoring.constants import BRACKET_INDEX_MAX, UPPER_BOUNDS +from common.anchoring.constants import PRICE_RANGE_INDEX_MAX, UPPER_BOUNDS -def calc_bracket_index(target_price: int) -> int: +def calc_price_range_index(target_price: int) -> int: """목표가 → 가격구간 인덱스(0-기반). 자릿수 계단식 사다리. 좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸. 1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1""" - return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX) + return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX) -def calc_anchor_price(target_price: int, rate_permille: int) -> int: +def calc_anchoring_price(target_price: int, anchoring_value: int) -> int: """앵커링가 = 목표가 × (1 − A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)""" - return target_price * (1000 - rate_permille) // 1000 + return target_price * (1000 - anchoring_value) // 1000 diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 349fdc7..c532691 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -245,8 +245,8 @@ class sessions(MainTableMixin, MAIN_BASE): qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷 qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷 target_price = Column(BigInteger, nullable=False) # 목표가(원) - target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준) - anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(천분율‰) 박제 — 위와 동일 규칙. 나머지 앵커링 컬럼(last_offered_price 등)은 backend/배치 소유라 매핑 안 함 + anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준) + anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(정수 ‰) 박제 — 위와 동일 규칙. 주의: quotation_settings.anchoring_value(구 float 비율)와 무관. 나머지 앵커링 컬럼(last_offer_price 등)은 backend/배치 소유라 매핑 안 함 status = Column(SmallInteger, nullable=False) # SessionStatus 코드 bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 0e77377..f144288 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -619,13 +619,13 @@ class QuotationCRUD(IQuotationCRUD): return ErrorType.DB_RUN_FAILED, 0 async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]: - """[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, target_anchoring_price). 삭제 제외. + """[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price). 삭제 제외. 공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None). target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값).""" try: query = ( select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name, - sessions.target_price, sessions.target_anchoring_price) + sessions.target_price, sessions.anchoring_price) .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) .where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712 ) diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 0a9078e..0d57776 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -94,7 +94,7 @@ class SessionData(WebPacketProtocol): qt_round: int qt_type: QuotationType target_price: int - target_anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1−앵커링율) + anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000−anchoring_value)//1000 status: SessionStatus bid_price: Optional[int] = None bid_at: Optional[datetime] = None @@ -203,4 +203,4 @@ class Res_TargetBreakdown(Res_WebPacketProtocol): candidates: list[TargetCandidate] = [] chosen_basis: Optional[str] = None target_price: int = 0 - target_anchoring_price: Optional[int] = None + anchoring_price: Optional[int] = None diff --git a/negodata/backend/scripts/seed_demo_quotations.sql b/negodata/backend/scripts/seed_demo_quotations.sql index 9be3f89..9f8f9f0 100644 --- a/negodata/backend/scripts/seed_demo_quotations.sql +++ b/negodata/backend/scripts/seed_demo_quotations.sql @@ -114,7 +114,7 @@ VALUES INSERT INTO negotiation.sessions (session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, bid_price, bid_at, end_time, - reject_reason, reject_price, reject_delivery_type, target_anchoring_price, email_sent_at, + reject_reason, reject_price, reject_delivery_type, anchoring_price, email_sent_at, created_at, updated_at, deleted) VALUES -- ① 낙찰(절감): S1 372,000(낙찰) / S2 389,000 / S3 395,000 diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 927990b..ff2739c 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -7,10 +7,10 @@ from fastapi import Depends from common.anchoring import ( SAMPLEABLE_SUPPLIER_TYPES, - calc_anchor_price, - calc_bracket_index, - fetch_current_rates, - get_base_rate_permille, + calc_anchoring_price, + calc_price_range_index, + fetch_current_values, + get_base_anchoring_value, ) from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards @@ -191,7 +191,7 @@ class QuotationService: res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands] res.chosen_basis = None if is_inherited else chosen_basis res.target_price = sess.target_price - res.target_anchoring_price = sess.target_anchoring_price + res.anchoring_price = sess.anchoring_price return res async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList: @@ -406,8 +406,8 @@ class QuotationService: rates = rates if _err == ErrorType.SUCCESS else {} fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수) margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율 - # 앵커링가는 quotation_settings.anchoring_value 를 더 이상 쓰지 않는다(앵커링 v1.2) — - # 칸(회사×협력사유형×가격구간)별 조정 rate 로 계산한다. 아래 세션 생성부 ②. + # 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) — + # 칸(회사×협력사유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②. # 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다. # (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.) @@ -478,32 +478,32 @@ class QuotationService: res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE) return res - # ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) rate 조회 후 + # ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) anchoring_value 조회 후 # 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는 - # 정적 테이블 시작값 폴백 — rate 조회 때문에 견적 생성이 실패하지 않는다(규칙 6). + # 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 6). _err, item_companies = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, lambda s: self.quotation_crud.get_item_companies(s, item_ids), ) item_companies = item_companies if _err == ErrorType.SUCCESS else {} - rate_map = {} + value_map = {} if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies: - rate_map = await DB_SESSION_MNG.execute_lambda( + value_map = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: fetch_current_rates(s, list(set(item_companies.values())), supplier_type), + lambda s: fetch_current_values(s, list(set(item_companies.values())), supplier_type), ) session_objs = [] for iid in item_ids: tp = target_prices[iid] - bracket = calc_bracket_index(tp) + price_range = calc_price_range_index(tp) company = item_companies.get(iid) - rate = rate_map.get((company, bracket)) if company is not None else None - if rate is None: - rate = get_base_rate_permille(bracket) - ap = calc_anchor_price(tp, rate) # 목표가×(1000−rate)//1000 — float 곱셈 금지(1원 내림 정확성) + 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: session_objs.append( sessions( @@ -515,8 +515,8 @@ class QuotationService: qt_round=quotation.round, qt_type=quotation.type, target_price=tp, - target_anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값) - anchor_rate_permille=rate, + anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값) + anchoring_value=value, status=SessionStatus.CREATED.value, end_time=quotation.end_time, ) @@ -648,7 +648,7 @@ class QuotationService: limit = rates.get("regen_limit") limit = limit if limit is not None else self.MAX_REGEN_PER_CAUSE target = next((r.target_price for r in rows if r.target_price is not None), None) - anchor = next((r.target_anchoring_price for r in rows if r.target_anchoring_price is not None), None) + anchor = next((r.anchoring_price for r in rows if r.anchoring_price is not None), None) # 재생성 총 이력(체인, 사유 무관). regen_limit = 체인 전체 재생성 총 한도. regen_used = await self._chain_regen_count(original.number, original.round) @@ -856,7 +856,7 @@ class QuotationService: qt_round=r.qt_round, qt_type=r.qt_type, target_price=r.target_price, - target_anchoring_price=r.target_anchoring_price, + anchoring_price=r.anchoring_price, status=r.status, bid_price=r.bid_price, bid_at=r.bid_at, diff --git a/negodata/backend/tests/test_quotation_anchoring.py b/negodata/backend/tests/test_quotation_anchoring.py index 885a062..3290537 100644 --- a/negodata/backend/tests/test_quotation_anchoring.py +++ b/negodata/backend/tests/test_quotation_anchoring.py @@ -1,29 +1,29 @@ -"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) rate 로 앵커가를 박제하는지 검증. +"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증. 이식 명세: schedules/anchoring/docs/인수인계.md §1. -- 앵커가 = 목표가 × (1000 − rate) // 1000 (정수 연산), anchor_rate_permille 동시 박제 +- 앵커가 = 목표가 × (1000 − anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제 - 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백, 견적 생성은 실패하지 않는다(규칙 6) -- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 rate 로 재계산(규칙 1 — 상속 폐지) +- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지) """ import uuid from datetime import datetime from sqlalchemy import text -from common.anchoring import calc_bracket_index +from common.anchoring import calc_price_range_index from common.enums import QuotationType from crud.quotation_crud import QuotationCRUD from router.v1.quotation.protocol import Req_CreateQuotation from services.quotation_service import QuotationService FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게 -BASE_RATE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 구간 0.01 +BASE_VALUE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 구간 0.01 -async def test_create_without_anchoring_schema_falls_back_to_base_rate(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(유통) 견적 생성. - 기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), rate=10 박제.""" + 기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), anchoring_value=10 박제.""" await _drop_anchoring(db_engine) item = await _seed_item(db_engine, company_id, internet_lowest=100_000) @@ -32,10 +32,10 @@ async def test_create_without_anchoring_schema_falls_back_to_base_rate(db_engine assert res.result.success is True tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200 rows = await _session_anchor_rows(db_engine, res.qt_id) - assert rows == {item: (tp, tp * (1000 - BASE_RATE) // 1000, BASE_RATE)} + assert rows == {item: (tp, tp * (1000 - BASE_VALUE) // 1000, BASE_VALUE)} -async def test_create_uses_latest_adjusted_rate_per_cell(db_engine, company_id): +async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id): """검증: 한 상품의 칸에만 조정 이력(50‰)을 넣고 상품 2개(다른 가격구간)로 견적 생성. 기대결과: 이력 칸 상품은 50‰, 무이력 칸 상품은 시작값 10‰ 로 각각 박제(칸 단위 조회).""" await _reset_anchoring(db_engine) @@ -43,29 +43,29 @@ async def test_create_uses_latest_adjusted_rate_per_cell(db_engine, company_id): item_miss = await _seed_item(db_engine, company_id, internet_lowest=5_000) # tp 4,610 — 다른 구간 tp_hit = int(100_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, bracket=calc_bracket_index(tp_hit), rate_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) assert res.result.success is True rows = await _session_anchor_rows(db_engine, res.qt_id) assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50) - assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_RATE) + assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE) -async def test_supplier_type_unset_uses_base_rate(db_engine, company_id): +async def test_supplier_type_unset_uses_base_value(db_engine, company_id): """검증: supplier_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, bracket=calc_bracket_index(tp), rate_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) assert res.result.success is True rows = await _session_anchor_rows(db_engine, res.qt_id) - assert rows == {item: (tp, tp * 990 // 1000, BASE_RATE)} + assert rows == {item: (tp, tp * 990 // 1000, BASE_VALUE)} async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id): @@ -79,28 +79,28 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa 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) - assert rows1 == {item: (tp, tp * 990 // 1000, BASE_RATE)} # 1라운드는 시작값 + assert rows1 == {item: (tp, tp * 990 // 1000, BASE_VALUE)} # 1라운드는 시작값 - await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp), rate_after=50) + await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50) res2 = await _service().regenerate_next_round(res1.qt_id, [supplier]) assert res2.result.success is True rows2 = await _session_anchor_rows(db_engine, res2.qt_id) - assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 rate + assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 anchoring_value -def test_bracket_index_golden_vectors(): - """검증: 이식된 calc_bracket_index 경계 골든 벡터(자릿수 사다리, 좌폐우개). - 배치의 박제 정합 감시는 rate↔앵커가 자기일관만 보므로 브래킷 이식 오류를 못 잡는다 — +def test_price_range_index_golden_vectors(): + """검증: 이식된 calc_price_range_index 경계 골든 벡터(자릿수 사다리, 좌폐우개). + 배치의 박제 정합 감시는 값↔앵커가 자기일관만 보므로 브래킷 이식 오류를 못 잡는다 — 이 벡터가 원본(schedules/anchoring)과 어긋나면 이식 오류다(값 변경 금지).""" - assert calc_bracket_index(0) == 0 # 최하단 통일 칸 [0, 1,000) - assert calc_bracket_index(999) == 0 - assert calc_bracket_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개) - assert calc_bracket_index(9_999) == 9 - assert calc_bracket_index(10_000) == 10 # 자릿수 전환 경계 - assert calc_bracket_index(99_999_999) == 45 - assert calc_bracket_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프 - assert calc_bracket_index(10**12) == 45 + assert calc_price_range_index(0) == 0 # 최하단 통일 칸 [0, 1,000) + assert calc_price_range_index(999) == 0 + assert calc_price_range_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개) + assert calc_price_range_index(9_999) == 9 + assert calc_price_range_index(10_000) == 10 # 자릿수 전환 경계 + assert calc_price_range_index(99_999_999) == 45 + assert calc_price_range_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프 + assert calc_price_range_index(10**12) == 45 # ===== 헬퍼 ===== @@ -140,16 +140,16 @@ async def _seed_item(engine, company_id, *, internet_lowest): async def _session_anchor_rows(engine, qt_id): - """생성된 견적의 item_id -> (target_price, target_anchoring_price, anchor_rate_permille).""" + """생성된 견적의 item_id -> (target_price, anchoring_price, anchoring_value).""" async with engine.begin() as conn: rows = (await conn.execute( - text("SELECT item_id, target_price, target_anchoring_price, anchor_rate_permille " + text("SELECT item_id, target_price, anchoring_price, anchoring_value " "FROM sessions WHERE quotation_id = :qt"), {"qt": qt_id}, )).all() out = {} - for item_id, tp, ap, rate in rows: - assert out.setdefault(item_id, (tp, ap, rate)) == (tp, ap, rate) # 같은 상품 세션끼리 동일 박제 + for item_id, tp, ap, value in rows: + assert out.setdefault(item_id, (tp, ap, value)) == (tp, ap, value) # 같은 상품 세션끼리 동일 박제 return out @@ -162,25 +162,25 @@ async def _drop_anchoring(engine): # negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다. _ANCHORING_DDL = ( "CREATE SCHEMA IF NOT EXISTS anchoring", - """CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments ( - id BIGSERIAL PRIMARY KEY, - company_id uuid NOT NULL, - supplier_type SMALLINT NOT NULL, - price_bracket_index INTEGER NOT NULL, - nego_count INTEGER NOT NULL, - success_count INTEGER NOT NULL, - anchor_rate_before SMALLINT NOT NULL, - anchor_rate_after SMALLINT NOT NULL, - consumed_session_ids JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + """CREATE TABLE IF NOT EXISTS anchoring.adjustments ( + adjustment_id BIGSERIAL PRIMARY KEY, + company_id uuid NOT NULL, + supplier_type SMALLINT NOT NULL, + price_range_index INTEGER NOT NULL, + sample_count INTEGER NOT NULL, + success_count INTEGER NOT NULL, + anchoring_value_before SMALLINT NOT NULL, + anchoring_value_after SMALLINT NOT NULL, + used_session_ids JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() )""", - """CREATE OR REPLACE VIEW anchoring.current_rates AS - SELECT DISTINCT ON (company_id, supplier_type, price_bracket_index) - company_id, supplier_type, price_bracket_index, - anchor_rate_after AS anchor_rate_permille, - id AS last_adjustment_id, created_at AS last_adjusted_at - FROM anchoring.rate_adjustments - ORDER BY company_id, supplier_type, price_bracket_index, id DESC""", + """CREATE OR REPLACE VIEW anchoring.current_values AS + SELECT DISTINCT ON (company_id, supplier_type, price_range_index) + company_id, supplier_type, price_range_index, + anchoring_value_after AS anchoring_value, + adjustment_id AS last_adjustment_id, created_at AS last_adjusted_at + FROM anchoring.adjustments + ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC""", ) @@ -192,15 +192,15 @@ async def _reset_anchoring(engine): await conn.execute(text(ddl)) -async def _seed_adjustment(engine, company_id, *, supplier_type, bracket, rate_after): +async def _seed_adjustment(engine, company_id, *, supplier_type, price_range, value_after): """칸에 조정 이력 1행 삽입(배치가 쌓는 행의 최소 재현).""" async with engine.begin() as conn: await conn.execute( text( - "INSERT INTO anchoring.rate_adjustments " - "(company_id, supplier_type, price_bracket_index, nego_count, success_count, " - " anchor_rate_before, anchor_rate_after, consumed_session_ids) " - "VALUES (:cid, :stype, :bracket, 10, 8, 10, :after, '[]'::jsonb)" + "INSERT INTO anchoring.adjustments " + "(company_id, supplier_type, price_range_index, sample_count, success_count, " + " anchoring_value_before, anchoring_value_after, used_session_ids) " + "VALUES (:cid, :stype, :price_range, 10, 8, 10, :after, '[]'::jsonb)" ), - {"cid": uuid.UUID(company_id), "stype": supplier_type, "bracket": bracket, "after": rate_after}, + {"cid": uuid.UUID(company_id), "stype": supplier_type, "price_range": price_range, "after": value_after}, ) diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 15890bd..26ef284 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -324,7 +324,7 @@ export * from './resTargetBreakdownMdPrice'; export * from './resTargetBreakdownMsg'; export * from './resTargetBreakdownPurchase'; export * from './resTargetBreakdownSelling'; -export * from './resTargetBreakdownTargetAnchoringPrice'; +export * from './resTargetBreakdownAnchoringPrice'; export * from './sessionData'; export * from './sessionDataBidAt'; export * from './sessionDataBidPrice'; @@ -332,7 +332,7 @@ export * from './sessionDataEmailSentAt'; export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectPrice'; export * from './sessionDataRejectReason'; -export * from './sessionDataTargetAnchoringPrice'; +export * from './sessionDataAnchoringPrice'; export * from './sessionStatus'; export * from './supplierData'; export * from './supplierDataCode'; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdown.ts b/negodata/front/src/api/generated/model/resTargetBreakdown.ts index 4697eed..4046b68 100644 --- a/negodata/front/src/api/generated/model/resTargetBreakdown.ts +++ b/negodata/front/src/api/generated/model/resTargetBreakdown.ts @@ -12,7 +12,7 @@ import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase'; import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling'; import type { TargetCandidate } from './targetCandidate'; import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis'; -import type { ResTargetBreakdownTargetAnchoringPrice } from './resTargetBreakdownTargetAnchoringPrice'; +import type { ResTargetBreakdownAnchoringPrice } from './resTargetBreakdownAnchoringPrice'; export interface ResTargetBreakdown { result?: ErrorInfo; @@ -29,5 +29,5 @@ export interface ResTargetBreakdown { candidates?: TargetCandidate[]; chosen_basis?: ResTargetBreakdownChosenBasis; target_price?: number; - target_anchoring_price?: ResTargetBreakdownTargetAnchoringPrice; + anchoring_price?: ResTargetBreakdownAnchoringPrice; } diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts b/negodata/front/src/api/generated/model/resTargetBreakdownAnchoringPrice.ts similarity index 64% rename from negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts rename to negodata/front/src/api/generated/model/resTargetBreakdownAnchoringPrice.ts index 34ce92e..5172e4b 100644 --- a/negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts +++ b/negodata/front/src/api/generated/model/resTargetBreakdownAnchoringPrice.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResTargetBreakdownTargetAnchoringPrice = number | null; +export type ResTargetBreakdownAnchoringPrice = number | null; diff --git a/negodata/front/src/api/generated/model/sessionData.ts b/negodata/front/src/api/generated/model/sessionData.ts index 69f19fb..ac9ff1a 100644 --- a/negodata/front/src/api/generated/model/sessionData.ts +++ b/negodata/front/src/api/generated/model/sessionData.ts @@ -5,7 +5,7 @@ * OpenAPI spec version: 0.1.0 */ import type { QuotationType } from './quotationType'; -import type { SessionDataTargetAnchoringPrice } from './sessionDataTargetAnchoringPrice'; +import type { SessionDataAnchoringPrice } from './sessionDataAnchoringPrice'; import type { SessionStatus } from './sessionStatus'; import type { SessionDataBidPrice } from './sessionDataBidPrice'; import type { SessionDataBidAt } from './sessionDataBidAt'; @@ -23,7 +23,7 @@ export interface SessionData { qt_round: number; qt_type: QuotationType; target_price: number; - target_anchoring_price?: SessionDataTargetAnchoringPrice; + anchoring_price?: SessionDataAnchoringPrice; status: SessionStatus; bid_price?: SessionDataBidPrice; bid_at?: SessionDataBidAt; diff --git a/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts b/negodata/front/src/api/generated/model/sessionDataAnchoringPrice.ts similarity index 66% rename from negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts rename to negodata/front/src/api/generated/model/sessionDataAnchoringPrice.ts index 510d4bd..6f3f32f 100644 --- a/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts +++ b/negodata/front/src/api/generated/model/sessionDataAnchoringPrice.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type SessionDataTargetAnchoringPrice = number | null; +export type SessionDataAnchoringPrice = number | null; diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx index 2a64ae0..0684956 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -103,7 +103,7 @@ export function DrawerHeaderCards({ {won(repSession?.target_price)} )} - + diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx index 5a60b69..4c23afa 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -181,12 +181,12 @@ export function SessionsStatusTab({ {sessionStatusLabel(sess.status)} - {sess.target_anchoring_price > 0 ? ( + {sess.anchoring_price > 0 ? ( <> - ₩{sess.target_anchoring_price.toLocaleString()} + ₩{sess.anchoring_price.toLocaleString()} {sess.target_price > 0 && ( - ({((1 - sess.target_anchoring_price / sess.target_price) * 100).toFixed(1)}%) + ({((1 - sess.anchoring_price / sess.target_price) * 100).toFixed(1)}%) )} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx index 8e5b3c9..4c5a404 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -131,7 +131,7 @@ export function TargetPriceModal({ 공급 업체 유형: {supplierTypeLabel} 앵커링 값: {bd.anchoring_value} - 앵커링가: {won(bd.target_anchoring_price)} = 목표가×(1−{bd.anchoring_value}) + 앵커링가: {won(bd.anchoring_price)} = 목표가×(1−{bd.anchoring_value}) diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index b7be156..bdeea51 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -218,7 +218,7 @@ export type SessionView = { item_name: string; status: number; target_price: number; - target_anchoring_price: number; + anchoring_price: number; bid_price: number | null; bid_at: string; reject_reason: string | null; @@ -359,7 +359,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ item_name: product?.name || '-', status: sd.status, target_price: sd.target_price ?? 0, - target_anchoring_price: sd.target_anchoring_price ?? 0, + anchoring_price: sd.anchoring_price ?? 0, bid_price: sd.bid_price ?? null, bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-', reject_reason: sd.reject_reason ?? null, diff --git a/postgres-init/01-schema_20260702.sql b/postgres-init/01-schema_20260702.sql index 81be772..8053e9c 100644 --- a/postgres-init/01-schema_20260702.sql +++ b/postgres-init/01-schema_20260702.sql @@ -299,10 +299,10 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions ( qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷) qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N) target_price BIGINT NOT NULL, -- 목표가(원) - target_anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조) - anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제 - last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적" - anchoring_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) + anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조) + anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제 + last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적" + used_by_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부) bid_price BIGINT NULL, -- 입찰가(원) bid_at TIMESTAMPTZ NULL, -- 입찰 시각 diff --git a/postgres-init/04-alter_20260702.sql b/postgres-init/04-alter_20260702.sql index 0c6d53d..7ee1605 100644 --- a/postgres-init/04-alter_20260702.sql +++ b/postgres-init/04-alter_20260702.sql @@ -14,7 +14,7 @@ ALTER TABLE partner.items -- 세션: 앵커링가 ALTER TABLE negotiation.sessions - ADD COLUMN IF NOT EXISTS target_anchoring_price BIGINT; + ADD COLUMN IF NOT EXISTS anchoring_price BIGINT; -- 견적: MD 제시가 + 협력사(공급채널) 유형 ALTER TABLE quotation.quotations @@ -54,12 +54,12 @@ CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notification -- ───────────────────────────────────────────────────────────── -- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종 --- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(rate_adjustments·뷰)는 +-- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(adjustments·뷰)는 -- 모듈 소유 DDL schedules/anchoring/schema.sql 로 적용 — 여기엔 두지 않는다.) ALTER TABLE negotiation.sessions - ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제 - ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적) - ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹 + ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제 + ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적) + ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹 -- ───────────────────────────────────────────────────────────── -- [2026-07-02] 마감 close_reason 개편 — 견적 마감사유 + 가격정책 3구간 diff --git a/schedules/anchoring/README.md b/schedules/anchoring/README.md index da8b1eb..ea532aa 100644 --- a/schedules/anchoring/README.md +++ b/schedules/anchoring/README.md @@ -12,14 +12,14 @@ | 구분 | 대상 | |---|---| -| 소유(쓰기) | `anchoring.rate_adjustments`(append-only 조정 이력), `sessions.anchoring_adjustment_id`(소비 마킹 — 이 컬럼만), Redis `anchor:*` 키 | +| 소유(쓰기) | `anchoring.adjustments`(append-only 조정 이력), `sessions.used_by_adjustment_id`(소비 마킹 — 이 컬럼만), Redis `anchor:*` 키 | | 읽기 전용 | `negotiation.sessions`(박제 컬럼), `quotation.quotations.supplier_type`, `partner.items.company_id` | -| 소비자 | negodata 가 reader 이식판으로 세션 생성 시 앵커가 박제 — **적용 완료(2026-07-04), 이식판은 Redis 미사용**(`current_rates` 뷰 직조회, 인수인계 §1) | +| 소비자 | negodata 가 reader 이식판으로 세션 생성 시 앵커가 박제 — **적용 완료(2026-07-04), 이식판은 Redis 미사용**(`current_values` 뷰 직조회, 인수인계 §1) | ## 구조 ``` -schema.sql # 모듈 소유 DDL (rate_adjustments + sessions 3컬럼) — psql 수동 적용 +schema.sql # 모듈 소유 DDL (adjustments + sessions 3컬럼) — psql 수동 적용 src/anchoring/ constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의) resources/anchoring_base.json # 정적 기본 테이블(46칸 사다리, 전부 10‰) — 불변, 시작값의 유일한 소스 @@ -46,7 +46,8 @@ PYTHONPATH=src .venv/bin/python -m anchoring.main --once --dry-run # 예행 연 PYTHONPATH=src .venv/bin/python -m anchoring.main --once # 수동 1회(격주 게이트 무시) PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주 -# 도커(자립 compose: redis 동봉) +# 도커(자립 compose: redis 동봉) — config.toml 은 이미지에 안 들어가고 마운트되므로 +# up 전에 파일이 먼저 있어야 한다 (없으면 docker 가 디렉터리를 만들어 기동 실패) docker compose up -d --build # 테스트 @@ -62,7 +63,7 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만 ``` - 타임스탬프는 항상 KST. 회차마다 `조정 company=... n=13 성공=8 10‰→30‰ adj_id=26`(칸별 상세)과 - `회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id` 로 `anchoring.rate_adjustments` 행과 교차 확인한다. + `회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id` 로 `anchoring.adjustments` 행과 교차 확인한다. - 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다. - 로그 로테이션은 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당. @@ -72,6 +73,6 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만 재기동 후 `--once` 1회 실행으로 즉시 캐치업(격주 게이트만 무시, 정책 파라미터 불변). - **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고 TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`. -- **가격 제시율 0% WARN**: backend 의 `last_offered_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검. +- **가격 제시율 0% WARN**: backend 의 `last_offer_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검. - **박제 정합 불일치 WARN**: negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) — `docs/인수인계.md` §1.3 점검 요청. -- 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `anchoring_adjustment_id` 하나뿐. +- 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `used_by_adjustment_id` 하나뿐. diff --git a/schedules/anchoring/TODO.md b/schedules/anchoring/TODO.md index 6c8cf70..f081682 100644 --- a/schedules/anchoring/TODO.md +++ b/schedules/anchoring/TODO.md @@ -17,11 +17,11 @@ ## ~~2. `anchoring_records` — 회사별 앵커링 값 전체 조회 테이블~~ ✅ 뷰로 종결 (2026-07-02) **신규 테이블 없이 조회용 뷰 2개로 해결.** 요구(회사별 값 업데이트 리스트업 + 이전 값 판별)는 -`rate_adjustments` 한 행에 before→after 가 박제되어 있어 이미 충족 — 테이블 추가는 사본만 만든다고 +`adjustments` 한 행에 before→after 가 박제되어 있어 이미 충족 — 테이블 추가는 사본만 만든다고 판단해 기각하고, 조회를 제품화하는 뷰를 추가했다: -- `anchoring.rate_history` — 회사별 값 변경 이력(이전→새 값, 변화폭, 성공률, 시각) -- `anchoring.current_rates` — 칸별 현재값(없는 칸 = 시작값 10‰) +- `anchoring.value_history` — 회사별 값 변경 이력(이전→새 값, 변화폭, 성공률, 시각) +- `anchoring.current_values` — 칸별 현재값(없는 칸 = 시작값 10‰) 상세: `docs/개발용.md` §6.3, 사용법: `docs/운영및유지보수.md` §8. 추후 대시보드에서 "전체 칸 나열(무조정 칸 포함)·페이징" 요구가 생기면 그때 스냅샷 테이블로 승격을 재검토한다. @@ -32,7 +32,7 @@ - [ ] **전환기 점프 정책 결정 (negodata 적용 직전 필수)**: backend 배포~negodata 적용 사이에 학습된 rate 가 적용 순간 한 번에 반영된다("한 계단" 원칙의 1회 예외). - 적용 직전 `SELECT max(anchor_rate_after) FROM anchoring.current_rates` 로 폭 확인 후 + 적용 직전 `SELECT max(anchoring_value_after) FROM anchoring.current_values` 로 폭 확인 후 점프 감수 vs 이력 아카이브·리셋을 결정할 것 — 절차는 `docs/인수인계.md` 적용 순서 ③. - [ ] **percent 입력 모드 대비**: `chat_service.send` 는 `user_input_type == "price"` 만 가격으로 @@ -43,7 +43,7 @@ 규모 DB 에 첫 적용할 때는 keyset 페이지네이션으로 전환 검토(제외 마킹은 이미 청크 커밋이라 트랜잭션 장기화 없음). - [ ] **Redis 통합 테스트**: 자동 스위트는 무Redis(폴백 경로)로 돈다. CI 에 redis 컨테이너가 - 생기면 §11.5 의 re-SET 회복·TTL·오염 값 방어(get_rate 범위 검증) 케이스를 자동화. + 생기면 §11.5 의 re-SET 회복·TTL·오염 값 방어(get_value 범위 검증) 케이스를 자동화. - [ ] **config 오류 메시지**: config.toml 의 오타 키가 TypeError 로 죽는다 — 파일/섹션명을 알려주는 검증 메시지로 개선. - [ ] **운영 Redis 인증**: compose 는 127.0.0.1 바인딩으로 방어했지만, 운영 네트워크에서 diff --git a/schedules/anchoring/docs/개발용.md b/schedules/anchoring/docs/개발용.md index ee20457..d9c5e40 100644 --- a/schedules/anchoring/docs/개발용.md +++ b/schedules/anchoring/docs/개발용.md @@ -34,11 +34,11 @@ | 기본 테이블 | **서비스 시작 시 메모리 로드되는 불변 정적 테이블** (`src/anchoring/resources/anchoring_base.json`, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스 | | 가격구간 | **자릿수 계단식 사다리(46칸)** — 최하단 [0, 1,000) 1칸 + 자릿수(1천~1억, 5개)마다 폭 = 자릿수 시작값(상한의 10%)인 9칸("1천 원대·2천 원대 … 9천만 원대"). 상한 = **정확히 1억**, `target_price > 1억`은 전부 **마지막 인덱스(45)** 로 클램프 | | 멀티테넌시 | 앵커링 값은 **회사(company)별로 독립** — 칸 키에 `company_id`(uuid) 포함 | -| 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`target_anchoring_price`, `anchor_rate_permille`, `last_offered_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 | -| 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offered_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 | +| 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`anchoring_price`, `anchoring_value`, `last_offer_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 | +| 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offer_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 | | 앵커 비노출 | agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작 | -| 조정 이력 저장 | **append-only 조정 이력** `anchoring.rate_adjustments` 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 | -| 소비 경계 | `sessions.anchoring_adjustment_id` 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = **한 트랜잭션** | +| 조정 이력 저장 | **append-only 조정 이력** `anchoring.adjustments` 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 | +| 소비 경계 | `sessions.used_by_adjustment_id` 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = **한 트랜잭션** | | 평가 트리거 | **격주 토요일 00:00 (KST)**, 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가 | | 평가 방식 | **누적 전량 평가**: 미처리 유효 표본 전부(n건)로 `r = 성공/n` 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 → 다음 주기 자연 이월 | | 앵커링가 반올림 | **1원 단위 내림(floor)** — 정수 연산만 사용 | @@ -83,8 +83,8 @@ |---|---| | 구간 범위 | `idx` k의 구간 = **`[이전 upper_bound, upper_bound)`** 좌폐우개 (idx 1 은 `[0, 1,000)`) | | 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 30,000원 → "3만 원대" 칸(idx 13) | -| 내부 인덱스 변환 | `bracket_index = idx − 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `bracket_index` 사용 | -| 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `bracket_index` 45). **1억 초과도 예외 없이 마지막 인덱스** | +| 내부 인덱스 변환 | `price_range_index = idx − 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `price_range_index` 사용 | +| 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `price_range_index` 45). **1억 초과도 예외 없이 마지막 인덱스** | | 시작값 | 칸의 시작 앵커링 값 = 해당 idx의 `anchoring_value` 천분율 변환 정수: `int(round(anchoring_value * 1000))`. 현재 전 구간 10‰ | | 기동 검증 | 로드 시 46행·idx 연속(1..46)·`upper_bound == constants.UPPER_BOUNDS[i]`(사다리 대조)·`0.01 ≤ anchoring_value ≤ 0.20` 검증, 실패 시 **기동 중단** (§13) | @@ -100,13 +100,13 @@ ```python # src/anchoring/constants.py -ANCHOR_RATE_MIN = 10 # 하한 1% -ANCHOR_RATE_MAX = 200 # 상한 20% +ANCHORING_VALUE_MIN = 10 # 하한 1% +ANCHORING_VALUE_MAX = 200 # 상한 20% # 시작값은 상수가 아니라 정적 테이블(§2)에서 로드 # 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드 # ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름) -DELTA_PERMILLE = { +ADJUSTMENT_STEP = { 1: 20, # 유통(DISTRIBUTION) ±2% 2: 10, # 제조(MANUFACTURE) ±1% 3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5% @@ -117,13 +117,13 @@ SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면 # 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드) PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스 UPPER_BOUNDS = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000) # 생성식으로 정의, 46개 -BRACKET_COUNT = 46 -BRACKET_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한 +PRICE_RANGE_COUNT = 46 +PRICE_RANGE_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한 EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에 # 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유) -MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값 +MARK_EXCLUDED = 0 # sessions.used_by_adjustment_id 제외 확정 마킹값 CACHE_TTL_SECONDS = 7 * 24 * 3600 # Redis 키 TTL(§7) — stale 잔존 방지 보조 REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백 @@ -133,7 +133,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백 ```python class SupplierType(Enum): - """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type + """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type (negodata SupplierType 과 동일 코드)""" NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외) DISTRIBUTION = 1 # 유통 @@ -157,13 +157,13 @@ class AnchoringSampleType(Enum): ### 4.1 칸(cell) 식별 -칸 = **`(company_id, supplier_type, bracket_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다. +칸 = **`(company_id, supplier_type, price_range_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다. ``` -bracket_index = min(bisect_right(UPPER_BOUNDS, target_price), 45) +price_range_index = min(bisect_right(UPPER_BOUNDS, target_price), 45) ``` -- `bracket_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45. +- `price_range_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45. - 칸 해석 소스: `company_id` = `partner.items.company_id` (세션의 item 소유 회사 = 갑), `supplier_type` = `quotation.quotations.supplier_type` (재협상 1:1 견적에 기록됨). - 같은 구간·유형이라도 회사가 다르면 **서로 다른 칸**. 회사 간 표본·값 공유 **MUST NOT**. - 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다. @@ -172,7 +172,7 @@ bracket_index = min(bisect_right(UPPER_BOUNDS, target_price), 45) ### 4.2 앵커링가 계산 ``` -anchor_price = target_price × (1000 − anchor_rate_permille) // 1000 +anchor_price = target_price × (1000 − anchoring_value) // 1000 ``` - `target_price`가 정수(원)이므로 위 식은 **정수 연산만으로 정확한 내림**을 보장한다. @@ -189,29 +189,29 @@ anchor_price = target_price × (1000 − anchor_rate_permille) // 1000 | 컬럼 | 의미 | 기록 시점 | |---|---|---| -| `sessions.target_anchoring_price` | 제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) | -| `sessions.anchor_rate_permille` | 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 | -| `sessions.last_offered_price` | 협력사 마지막 제시가 = **가격 흔적** (NULL = 가격을 써낸 적 없음) | backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변 | +| `sessions.anchoring_price` | 제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) | +| `sessions.anchoring_value` | 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 | +| `sessions.last_offer_price` | 협력사 마지막 제시가 = **가격 흔적** (NULL = 가격을 써낸 적 없음) | backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변 | | `sessions.status` / `bid_price` | 종료 상태 / 확정 투찰가 | 세션 종료 시 확정 | 판정 대상: `qt_type = 1(재협상)` AND `status ∈ {3 DONE, 4 NOT_PARTICIPATED, 5 REJECTED}` AND `deleted = false`. | 판정 | 조건 | 유효 표본 | 성공 | |---|---|---|---| -| `BID_SUCCESS` | `status=DONE` AND `bid_price ≤ target_anchoring_price` | O | O | +| `BID_SUCCESS` | `status=DONE` AND `bid_price ≤ anchoring_price` | O | O | | `BID_FAIL` | 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / **가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)** | O | X | -| `EXCLUDED` | `last_offered_price IS NULL`(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 | **X** | — | +| `EXCLUDED` | `last_offer_price IS NULL`(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 | **X** | — | - "유효 표본" = `EXCLUDED`가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다. - **왜 실패에 결렬·이탈이 반드시 포함돼야 하나**: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(`check_price_match`) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다. -- 판정 입력 컬럼은 종료 후 **절대 수정 금지** (MUST NOT — §12). `last_offered_price` 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다. +- 판정 입력 컬럼은 종료 후 **절대 수정 금지** (MUST NOT — §12). `last_offer_price` 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다. ### 4.4 평가 산식 (누적 전량 평가) 배치 시점에 칸별로 수행한다. ``` -pending = 해당 칸의 미처리(anchoring_adjustment_id IS NULL) 유효 표본 전부 +pending = 해당 칸의 미처리(used_by_adjustment_id IS NULL) 유효 표본 전부 n = |pending| n < 10 → 평가하지 않음. 마킹도 하지 않음 → 다음 주기로 이월 (자동으로 4주, 6주, …치가 됨) @@ -221,16 +221,16 @@ n ≥ 10 → r = (pending 중 BID_SUCCESS 건수) / n delta = ┤ 0 if 0.30 ≤ r < 0.60 └ −δ(p) if r < 0.30 -anchor_rate_after = clamp(anchor_rate_before + delta, 10, 200) +anchoring_value_after = clamp(anchoring_value_before + delta, 10, 200) → 한 트랜잭션으로: - ① anchoring.rate_adjustments INSERT (n, success, before/after, consumed_session_ids 박제) - ② 소비 세션 UPDATE sessions SET anchoring_adjustment_id = <조정 id> - WHERE session_id IN (...) AND anchoring_adjustment_id IS NULL ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST) + ① anchoring.adjustments INSERT (n, success, before/after, used_session_ids 박제) + ② 소비 세션 UPDATE sessions SET used_by_adjustment_id = <조정 id> + WHERE session_id IN (...) AND used_by_adjustment_id IS NULL ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST) ``` - **분모는 항상 실제 누적 건수 n** (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다. - delta = 0이어도, clamp에 막혀 값이 안 변해도 **조정 레코드는 반드시 INSERT**하고 표본을 소비(마킹)한다 (MUST). -- "표본 소비" = 마킹. 물리 삭제 없음. `EXCLUDED`·칸 구성 불가 세션은 평가와 무관하게 `anchoring_adjustment_id = 0`으로 일괄 마킹해 재스캔을 방지한다. +- "표본 소비" = 마킹. 물리 삭제 없음. `EXCLUDED`·칸 구성 불가 세션은 평가와 무관하게 `used_by_adjustment_id = 0`으로 일괄 마킹해 재스캔을 방지한다. - 한 칸은 한 배치에서 **최대 1회** 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장). ### 4.5 현재 앵커링 값 조회 @@ -238,12 +238,12 @@ anchor_rate_after = clamp(anchor_rate_before + delta, 10, 200) 값은 저장된 단일 상태가 아니라 **조정 이력의 최신 행**이다. ``` -rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after +rate = (칸의 최신 anchoring.adjustments 행).anchoring_value_after 없으면 → 정적 테이블 시작값 (§2.1) ``` -- 재현성: 조정 행에 박제된 `consumed_session_ids`(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. **조정 이력은 유일 진실 원천**이며 보호 대상이다 (백업 정책 적용 MUST). -- 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 `consumed_session_ids`를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 **MUST NOT** (배치 시각 의존이므로 불가능). +- 재현성: 조정 행에 박제된 `used_session_ids`(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. **조정 이력은 유일 진실 원천**이며 보호 대상이다 (백업 정책 적용 MUST). +- 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 `used_session_ids`를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 **MUST NOT** (배치 시각 의존이므로 불가능). - 알려진 완화: `sessions` 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다. --- @@ -256,22 +256,22 @@ rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after [견적/세션 생성 — negodata, 인수인계 §9.1] │ 칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000−rate)//1000 (정수) - │ → 세션 INSERT 에 target_anchoring_price + anchor_rate_permille 박제 (재생성 상속 폐지) + │ → 세션 INSERT 에 anchoring_price + anchoring_value 박제 (재생성 상속 폐지) ▼ [협상 채팅 — backend, §9.2 — anchoring 모듈 무의존] │ 박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계) - │ 가격 입력 턴마다 last_offered_price 갱신 (가격 흔적) + │ 가격 입력 턴마다 last_offer_price 갱신 (가격 흔적) ▼ negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼) │ │ 격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3) │ → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션) ▼ -anchoring.rate_adjustments ────────── 진실 원천 (INSERT only, consumed_session_ids·값 변화 박제) +anchoring.adjustments ────────── 진실 원천 (INSERT only, used_session_ids·값 변화 박제) │ │ 배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합) ▼ -Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7일 +Redis anchor:{company_id}:{supplier_type}:{price_range_index} → rate(‰), TTL 7일 │ │ GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블) ▼ @@ -279,7 +279,7 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7 ``` - 조정 이력 테이블에 UPDATE / DELETE **MUST NOT**. -- 배치가 `sessions`에 쓰는 것은 `anchoring_adjustment_id` **단 하나** — 다른 컬럼 수정 MUST NOT. +- 배치가 `sessions`에 쓰는 것은 `used_by_adjustment_id` **단 하나** — 다른 컬럼 수정 MUST NOT. - 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외). - 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요. - Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8). @@ -294,14 +294,14 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7 | 개념 | 명칭 | 이유 | |---|---|---| -| 조정 이력 테이블 | **`anchoring.rate_adjustments`** | 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현 | +| 조정 이력 테이블 | **`anchoring.adjustments`** | 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현 | | 협력사 유형 | **`supplier_type`** | 기존 `quotations.supplier_type`과 용어 통일 | -| 가격구간 | **`price_bracket_index`** | 가격구간임을 명시 (코드 내부 변수는 `bracket_index`) | -| 표본 수 | **`nego_count`** | "협상 결과 n건" — 정책 문서 용어 | -| 값 변화 | **`anchor_rate_before` / `anchor_rate_after`** | `sessions.anchor_rate_permille`와 계열 통일 (‰) | -| 소비 창 | **`consumed_session_ids`** | "이 조정이 소비한 세션"임을 명시 | +| 가격구간 | **`price_range_index`** | 가격구간임을 명시 (코드 내부 변수는 `price_range_index`) | +| 표본 수 | **`sample_count`** | "협상 결과 n건" — 정책 문서 용어 | +| 값 변화 | **`anchoring_value_before` / `anchoring_value_after`** | `sessions.anchoring_value`와 계열 통일 (‰) | +| 소비 창 | **`used_session_ids`** | "이 조정이 소비한 세션"임을 명시 | | 생성 시각 | **`created_at`** | 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각) | -| 소비 마킹 | **`sessions.anchoring_adjustment_id`** | 조정 테이블명과 정합 | +| 소비 마킹 | **`sessions.used_by_adjustment_id`** | 조정 테이블명과 정합 | ### 6.1 조정 이력 (신설 — 유일한 새 테이블) @@ -309,56 +309,56 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7 CREATE SCHEMA IF NOT EXISTS anchoring; -- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략. -CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments ( +CREATE TABLE IF NOT EXISTS anchoring.adjustments ( id BIGSERIAL PRIMARY KEY, company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래) supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15) - price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장) - nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장) + price_range_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장) + sample_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장) success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수 - anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값) - anchor_rate_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장 - consumed_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사) + anchoring_value_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값) + anchoring_value_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장 + used_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사) created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- 현재 값 조회 최적화: 칸별 최신 조정 -CREATE INDEX IF NOT EXISTS idx_rate_adjustments_cell - ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC); +CREATE INDEX IF NOT EXISTS idx_adjustments_cell + ON anchoring.adjustments (company_id, supplier_type, price_range_index, id DESC); ``` ### 6.2 sessions 확장 (기존 테이블 ALTER) ```sql ALTER TABLE negotiation.sessions - ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 rate(‰) 박제 - ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변 - ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id + ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 rate(‰) 박제 + ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변 + ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id -- 배치 스캔 최적화: 미처리 "재협상" 세션만 (qt_type=1 을 술어에 포함 MUST — -- 빼면 배치가 마킹하지 않는 비재협상 세션이 영구 잔류해 인덱스가 무한 성장) CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending ON negotiation.sessions (status) - WHERE anchoring_adjustment_id IS NULL AND deleted = false AND qt_type = 1; + WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1; ``` ### 6.3 조회용 뷰 (파생 — 상태 없음) -회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 rate_adjustments 그대로): +회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 adjustments 그대로): ```sql -anchoring.rate_history -- 값 변경 이력 리스트업: 이전 값(anchor_rate_before)→새 값 + delta_permille·success_rate·created_at -anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰) +anchoring.value_history -- 값 변경 이력 리스트업: 이전 값(anchoring_value_before)→새 값 + value_change·success_rate·created_at +anchoring.current_values -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰) ``` - 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다. -- "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 rate_adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다. +- "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다. 주의사항: -- `sessions.target_anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님). +- `sessions.anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님). - 신규 DB 구축 시 적용 순서: `postgres-init/01~04` → `schedules/anchoring/schema.sql` (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 `postgres-init/01-schema*.sql`·`04-alter*.sql` 에도 반영돼 있다**(backend 가 모듈 DDL 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 모듈 파일만이 소유. -- backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `rate_adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함). +- backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함). --- @@ -366,7 +366,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 | 항목 | 규약 | |---|---| -| 키 | `anchor:{company_id}:{supplier_type}:{bracket_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` | +| 키 | `anchor:{company_id}:{supplier_type}:{price_range_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` | | 값 | 정수 천분율 문자열. 예: `"30"` | | TTL | **7일** (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) | | 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → **SET NX**(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지 | @@ -377,16 +377,16 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 - 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST). - ⚠️ **stale 키는 "미스"가 나지 않는다**: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다. - 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요. -- 클라이언트: `redis.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)뿐이다. backend 는 Redis 를 쓰지 않고, **negodata 도 쓰지 않는다**(2026-07-04 적용된 이식판 reader 는 `current_rates` 뷰 직조회 — 인수인계 §1. Redis 캐시 전체 제거가 후속 백로그로 확정됨). 설정은 모듈 `config.toml` + `REDIS_HOST/PORT/PASSWORD` env 오버라이드. +- 클라이언트: `redis.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)뿐이다. backend 는 Redis 를 쓰지 않고, **negodata 도 쓰지 않는다**(2026-07-04 적용된 이식판 reader 는 `current_values` 뷰 직조회 — 인수인계 §1. Redis 캐시 전체 제거가 후속 백로그로 확정됨). 설정은 모듈 `config.toml` + `REDIS_HOST/PORT/PASSWORD` env 오버라이드. - 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. 모듈 compose 는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다(TODO 백로그). --- ## 8. 배치 잡 명세 -- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·docker-compose·config.toml 보유, backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지). +- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·docker-compose·config.toml 보유, backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). 플래그는 argparse 로 검증한다 — `--dry-run` 단독(상주에 dry-run 은 없음)·미지의 플래그(오타)는 **기동 전 즉시 에러(종료코드 2)**: 예행인 줄 알고 실제 변경 상주 스케줄러가 뜨는 사고를 차단. `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지). config.toml 은 이미지에 넣지 않는다(.dockerignore 포함) — compose 가 읽기 전용 마운트하거나 env 로 주입. - **스케줄**: 매주 토 00:00 KST 트리거(`CronTrigger(day_of_week="sat", hour=0, minute=0)`) + 잡 내부에서 **ISO 주차 % 2 == EVAL_WEEK_PARITY** 격주 게이트 (기준 패리티는 상수 고정 MUST). -- **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND anchoring_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다. +- **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND used_by_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다. - **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.) ``` @@ -397,31 +397,32 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 (SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7) 1. 미처리 종료 재협상 세션 스캔 (LEFT JOIN + ON 절 deleted 필터 — §10 SQL 참조): sessions s LEFT JOIN quotations q (deleted=false) LEFT JOIN items i (deleted=false) - WHERE s.anchoring_adjustment_id IS NULL AND s.deleted = false + WHERE s.used_by_adjustment_id IS NULL AND s.deleted = false AND s.qt_type = 1 AND s.status IN (3, 4, 5) 2. 세션별 파생 판정(§4.3): - EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석) - → anchoring_adjustment_id = 0 일괄 마킹 (재스캔 방지) + → used_by_adjustment_id = 0 일괄 마킹 (재스캔 방지) - 유효 표본 → 칸별 그룹 적재 - - 박제 정합 감시: rate 가 박제된 세션에 대해 tp×(1000−anchor_rate_permille)//1000 과 + - 박제 정합 감시: rate 가 박제된 세션에 대해 calc_anchoring_price(tp, anchoring_value)(§4.2 정수식)와 박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch — negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는 검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다) 3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음): - anchor_rate_before = 최신 조정 anchor_rate_after (없으면 정적 테이블 시작값) - anchor_rate_after = evaluate_pending(...) # §4.4 / §10 - ① anchoring.rate_adjustments INSERT (consumed_session_ids 박제) + anchoring_value_before = 최신 조정 anchoring_value_after (없으면 정적 테이블 시작값) + anchoring_value_after = evaluate_samples(...) # §4.4 / §10 + ① anchoring.adjustments INSERT (used_session_ids 박제) ② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST) -4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchor_rate_after (best effort, TTL 7일) -5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / 상·하한 도달 / 이월 칸 수 / 제외 마킹 건수 - + 가격 제시율(종료 재협상 세션 중 last_offered_price 보유 비율) — 0% 면 WARN +4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchoring_value_after (best effort, TTL 7일) +5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / clamp 포화(조정치가 상·하한 밖으로 나가 잘린 칸 — + 경계값에서의 단순 '유지'는 세지 않음) / 이월 칸 수 / 제외 마킹 건수 + + 가격 제시율(종료 재협상 세션 중 last_offer_price 보유 비율) — 0% 면 WARN (backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지) ``` **로그 규약** (운영 추적): - 출력 = stdout(컨테이너 json-file 드라이버, compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**. -- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= bracket=` key=value 형식 → **회사별 grep**(`grep company=`). +- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= price_range=` key=value 형식 → **회사별 grep**(`grep company=`). - 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → **칸별 조정 상세**(`n= 성공= before‰→after‰ adj_id=` — DB 행과 교차 확인) → **회사요약**(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약. - 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN. - 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화). @@ -443,21 +444,21 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 ### 9.1 견적/세션 생성 — negodata (인수인계 대상) > ✅ 2026-07-04 적용 완료(인수인계.md §1 참조). 적용된 이식판은 아래 2번의 Redis GET/SET 없이 -> `current_rates` 뷰 직조회 → 정적 테이블 폴백으로 동작한다(단순화 결정). +> `current_values` 뷰 직조회 → 정적 테이블 폴백으로 동작한다(단순화 결정). -구(舊) negodata `_build_quotation`은 세션 생성 시 `target_anchoring_price`를 구 방식으로 채웠다 +구(舊) negodata `_build_quotation`은 세션 생성 시 `anchoring_price`를 구 방식으로 채웠다 (신규: `int(tp * (1 - quotation_settings.anchoring_value))` float 계산 / 재생성: 직전 라운드 값 상속). 새 앵커링 모듈 전달 후 아래로 교체된다: ``` 세션(상품 × 공급사) 생성 시마다: 1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type - bracket_index = calc_bracket_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식 + price_range_index = calc_price_range_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식 2. rate 조회 (모듈의 reader 이식): supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET 그 외(미지정 등) → 정적 테이블 시작값 (유일 폴백 — §12) 3. anchor_price = target_price × (1000 − rate) // 1000 ← 정수 연산 MUST (기존 float 식 폐기) -4. 세션 INSERT 에 target_anchoring_price = anchor_price, anchor_rate_permille = rate 포함 (박제) +4. 세션 INSERT 에 anchoring_price = anchor_price, anchoring_value = rate 포함 (박제) ``` - **재생성 상속 폐지 (MUST)**: 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지. @@ -469,7 +470,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 `backend/services/chat_service.py::_resolve_anchor_price` — `quotation_settings.anchoring_value` 읽기 **삭제**. `_agent_context`가 오프닝 seed·send 양쪽의 단일 진입점이다. backend 는 anchoring 모듈·Redis·정적 테이블을 일절 사용하지 않는다. ``` -1. 박제값 사용 (MUST): sessions.target_anchoring_price 를 그대로 사용. +1. 박제값 사용 (MUST): sessions.anchoring_price 를 그대로 사용. → negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로. → 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제) 2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그. @@ -483,7 +484,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 **가격 흔적 기록** (MUST): - agent 는 **변경하지 않는다**. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다. -- backend `send()`가 가격 입력 턴(`price is not None`)의 봇 메시지를 저장하는 트랜잭션에 `UPDATE sessions SET last_offered_price = :price WHERE session_id = :id`를 함께 넣는다 — 메시지 저장과 **원자적**, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3). +- backend `send()`가 가격 입력 턴(`price is not None`)의 봇 메시지를 저장하는 트랜잭션에 `UPDATE sessions SET last_offer_price = :price WHERE session_id = :id`를 함께 넣는다 — 메시지 저장과 **원자적**, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3). - 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST). --- @@ -495,46 +496,48 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 from bisect import bisect_right from anchoring.constants import ( - ANCHOR_RATE_MIN, ANCHOR_RATE_MAX, DELTA_PERMILLE, - SAMPLE_THRESHOLD, UPPER_BOUNDS, BRACKET_INDEX_MAX, + ANCHORING_VALUE_MIN, ANCHORING_VALUE_MAX, ADJUSTMENT_STEP, + SAMPLE_THRESHOLD, UPPER_BOUNDS, PRICE_RANGE_INDEX_MAX, AnchoringSampleType, ) -from anchoring.base_table import get_base_rate_permille # 정적 테이블 조회 (§2) +from anchoring.base_table import get_base_anchoring_value # 정적 테이블 조회 (§2) -def calc_bracket_index(target_price: int) -> int: +def calc_price_range_index(target_price: int) -> int: """목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리. 좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1""" - return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX) + return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX) -def calc_anchor_price(target_price: int, rate_permille: int) -> int: +def calc_anchoring_price(target_price: int, anchoring_value: int) -> int: """앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)""" - return target_price * (1000 - rate_permille) // 1000 + return target_price * (1000 - anchoring_value) // 1000 def judge_sample_type( is_done: bool, # sessions.status == DONE(3) bid_price: int | None, # 확정 투찰가(DONE 시) - last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음 - anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커) + last_offer_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음 + anchor_price: int | None, # sessions.anchoring_price (박제 앵커) ) -> int: """배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적.""" - if anchor_price is None or last_offered_price is None: + if anchor_price is None or last_offer_price is None: return AnchoringSampleType.EXCLUDED.value if is_done and bid_price is not None and bid_price <= anchor_price: return AnchoringSampleType.BID_SUCCESS.value return AnchoringSampleType.BID_FAIL.value -def evaluate_pending( - rate_before: int, +def evaluate_samples( + value_before: int, sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드 supplier_type: int, # SMALLINT 코드 1/2/3 -) -> int | None: +) -> tuple[int, bool] | None: """누적 전량 평가. §4.4 - 반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월) + 반환: (anchoring_value_after, clamped) (평가 수행 시) / None (n < 10, 스킵·이월) + clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 — 경계값에서의 '유지'와 + 구분되는 실제 포화 신호(운영 지표용). 호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다. """ n = len(sample_types) @@ -542,24 +545,25 @@ def evaluate_pending( return None success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value) - delta = DELTA_PERMILLE[supplier_type] + delta = ADJUSTMENT_STEP[supplier_type] # r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피) if success * 10 >= n * 6: - adjusted = rate_before + delta + adjusted = value_before + delta elif success * 10 < n * 3: # r < 0.30 - adjusted = rate_before - delta + adjusted = value_before - delta else: # 0.30 ≤ r < 0.60 - adjusted = rate_before + adjusted = value_before - return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted)) + value_after = max(ANCHORING_VALUE_MIN, min(ANCHORING_VALUE_MAX, adjusted)) + return value_after, value_after != adjusted -def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int: +def get_current_value(latest_adjusted_rate: int | None, price_range_index: int) -> int: """현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값.""" if latest_adjusted_rate is not None: return latest_adjusted_rate - return get_base_rate_permille(bracket_index) # int(round(anchoring_value * 1000)) + return get_base_anchoring_value(price_range_index) # int(round(anchoring_value * 1000)) ``` ```sql @@ -567,12 +571,12 @@ def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> in -- LEFT JOIN + ON 절 deleted 필터: 삭제·소실된 견적/상품의 세션은 칸 해석이 NULL 이 되어 -- 제외 마킹(0)으로 정리된다 — 철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지. SELECT s.session_id, s.status, s.bid_price, s.target_price, - s.target_anchoring_price, s.last_offered_price, + s.anchoring_price, s.last_offer_price, q.supplier_type, i.company_id FROM negotiation.sessions s LEFT JOIN quotation.quotations q ON q.qt_id = s.quotation_id AND q.deleted = false LEFT JOIN partner.items i ON i.item_id = s.item_id AND i.deleted = false -WHERE s.anchoring_adjustment_id IS NULL +WHERE s.used_by_adjustment_id IS NULL AND s.deleted = false AND s.qt_type = 1 AND s.status IN (3, 4, 5) @@ -580,9 +584,9 @@ WHERE s.anchoring_adjustment_id IS NULL ```sql -- 현재 값 조회 (캐시 미스 시) -SELECT anchor_rate_after -FROM anchoring.rate_adjustments -WHERE company_id = :c AND supplier_type = :p AND price_bracket_index = :b +SELECT anchoring_value_after +FROM anchoring.adjustments +WHERE company_id = :c AND supplier_type = :p AND price_range_index = :b ORDER BY id DESC LIMIT 1 ``` @@ -604,7 +608,7 @@ LIMIT 1 ### 11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함) -| target_price | bracket_index | 정적 테이블 idx | 칸 | +| target_price | price_range_index | 정적 테이블 idx | 칸 | |---|---|---|---| | 0 | 0 | 1 | [0, 1,000) 통일 칸 | | 999 | 0 | 1 | [0, 1,000) | @@ -619,9 +623,9 @@ LIMIT 1 정적 테이블 검증: 46행 · idx 1..46 연속 · `upper_bound == UPPER_BOUNDS[i]`(사다리 대조) · 마지막 100,000,000. -### 11.3 누적 전량 평가 (유통 코드1, δ=20, rate_before=10) +### 11.3 누적 전량 평가 (유통 코드1, δ=20, value_before=10) -| pending 구성 | n | r | 판정 | anchor_rate_after | +| pending 구성 | n | r | 판정 | anchoring_value_after | |---|---|---|---|---| | 성공 8 / 실패 5 | 13 | ≈ 0.615 | ≥ 0.60 → +20 | **30** | | 성공 7 / 실패 6 | 13 | ≈ 0.538 | 유지 | **10** | @@ -630,20 +634,20 @@ LIMIT 1 | 성공 3 / 실패 7 | 10 | 0.30 정확히 | 유지 | **10** | | 성공 9 / 실패 0 | 9 | — | **평가 안 함 (이월)** | None | -**δ 스왑 가드 (MUST)**: `evaluate_pending(10, [성공10/10], supplier_type=2) == 20` (제조 +10), `supplier_type=3 → 25` (총판 +15). +**δ 스왑 가드 (MUST)**: `evaluate_samples(10, [성공10/10], supplier_type=2) == (20, False)` (제조 +10), `supplier_type=3 → (25, False)` (총판 +15). clamp·격리 케이스: | 시나리오 | 기대 | |---|---| -| rate_before 200, r = 0.9 | 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨 | +| value_before 200, r = 0.9 | 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨 | | A사 칸 평가 | B사의 같은 (p, b) 칸 값에 영향 없음 | | 조정 이력 없는 칸 | 정적 테이블 시작값(10) 반환 | | EXCLUDED 15건 + 유효 5건 | 평가 안 함 (유효 5 < 10), EXCLUDED 는 마킹 0 처리 | ### 11.4 파생 판정 -| status | bid_price | last_offered_price | anchor_price | 기대 | +| status | bid_price | last_offer_price | anchor_price | 기대 | |---|---|---|---|---| | DONE | 24,000 | 24,000 | 24,000 | BID_SUCCESS (같아도 성공) | | DONE | 24,001 | 24,001 | 24,000 | BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등) | @@ -656,7 +660,7 @@ clamp·격리 케이스: | 시나리오 | 기대 | |---|---| -| 유효 13건 시드 → 배치 | 조정 1행(n=13, consumed_session_ids 13개 박제, 10→30) + 13건 모두 `anchoring_adjustment_id`=조정 id | +| 유효 13건 시드 → 배치 | 조정 1행(n=13, used_session_ids 13개 박제, 10→30) + 13건 모두 `used_by_adjustment_id`=조정 id | | 직후 배치 재실행 | 조정 0건 (전 칸 pending < 10 — 마킹 멱등) | | 2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 | 4주 차 배치에서 13건 전량 1회 평가 | | 배치 1회 누락 → 다음 배치 | 4주치 pending으로 1스텝 평가, 별도 보정 불필요 | @@ -673,8 +677,8 @@ clamp·격리 케이스: | 시나리오 | 기대 | |---|---| -| 재협상 채팅 → 가격 입력 턴 | `last_offered_price` 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) | -| 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | `last_offered_price` 보존 → 배치에서 BID_FAIL 표본 | +| 재협상 채팅 → 가격 입력 턴 | `last_offer_price` 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) | +| 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | `last_offer_price` 보존 → 배치에서 BID_FAIL 표본 | | 같은 세션에서 배치가 값 변경 후 다음 턴 | 앵커 불변 (박제값 사용) | | 박제 없는 세션(NULL 폴백) | anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작 | | Redis 정지 상태에서 견적 생성(negodata reader) | DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백) | @@ -688,7 +692,7 @@ clamp·격리 케이스: - **극희소 칸 fallback** (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현). - **정적 기본 테이블 변경** — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안. - **조정 이력의 UPDATE/DELETE, 소급 무효화·보정** — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계. -- **sessions 판정 입력 컬럼(`target_anchoring_price`, `anchor_rate_permille`)의 사후 수정, `last_offered_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `anchoring_adjustment_id` 단 하나. +- **sessions 판정 입력 컬럼(`anchoring_price`, `anchoring_value`)의 사후 수정, `last_offer_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `used_by_adjustment_id` 단 하나. - **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정. - **성공률 외 신호 반영** (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐. - **회사 간 값·표본 공유 또는 전사 통합 평가** — 칸은 회사별 완전 독립. @@ -703,7 +707,7 @@ clamp·격리 케이스: | # | 항목 | 담당 | 상태 | |---|---|---|---| -| 1 | DDL — `anchoring.rate_adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) | +| 1 | DDL — `anchoring.adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) | | 2 | **세션 생성 시 앵커 산출을 새 시스템으로 교체** — `_build_quotation` 앵커 계산 교체 + 재생성 상속 폐지 | **[인수인계 — negodata]** | §9.1. reader 는 모듈(async)에서 그대로 이식 | | 3 | agent | **변경 없음** | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 | | 4 | 재협상 식별 | — | `sessions.qt_type = 1` 로 판별 (확인됨) | @@ -712,4 +716,4 @@ clamp·격리 케이스: | 7 | 정적 테이블 로드 검증 | [우리 — 모듈] | 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료 | | 8 | Redis 인프라 | [우리 — 모듈] | 모듈 docker-compose 에 redis 동봉, negodata 가 같은 인스턴스 참조. backend 는 Redis 무의존 | | 9 | 스케줄러·배치 | [우리 — 모듈] | 자립 컨테이너(APScheduler, `--once` 수동 실행 지원). 구현 완료 | -| 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offered_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 | +| 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offer_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 | diff --git a/schedules/anchoring/docs/운영및유지보수.md b/schedules/anchoring/docs/운영및유지보수.md index d326f71..ffee753 100644 --- a/schedules/anchoring/docs/운영및유지보수.md +++ b/schedules/anchoring/docs/운영및유지보수.md @@ -32,7 +32,7 @@ ``` [anchoring 컨테이너] ──── 격주 배치 실행 (APScheduler 내장) │ 읽기: negotiation.sessions / quotation.quotations / partner.items - │ 쓰기: anchoring.rate_adjustments (조정 이력) + sessions.anchoring_adjustment_id (채점 마킹) + │ 쓰기: anchoring.adjustments (조정 이력) + sessions.used_by_adjustment_id (채점 마킹) ▼ [PostgreSQL (외부, negosium_db)] [anchoring-redis 컨테이너] 진실 원천 — 영구 이력 조회 캐시(사본) — 없어져도 복구됨 @@ -58,7 +58,7 @@ cd schedules/anchoring psql -h -U <계정> -d negosium_db -f schema.sql ``` -- 테이블 1개(`anchoring.rate_adjustments`)·조회용 뷰 2개(`rate_history`, `current_rates`)와 `negotiation.sessions` 컬럼 3개를 추가합니다. +- 테이블 1개(`anchoring.adjustments`)·조회용 뷰 2개(`value_history`, `current_values`)와 `negotiation.sessions` 컬럼 3개를 추가합니다. - `IF NOT EXISTS` 라 **여러 번 실행해도 안전**합니다. ### STEP 2 — 설정 채우기 @@ -71,6 +71,10 @@ cp config.toml.example config.toml 환경변수로 덮어쓸 수도 있습니다(우선순위: env > config.toml > 기본값): `DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` / `REDIS_HOST` `REDIS_PORT` `REDIS_DB` `REDIS_PASSWORD` / `LOG_LEVEL` +> config.toml 은 **이미지에 들어가지 않습니다**(시크릿이 이미지 레이어에 남는 것을 방지 — .dockerignore 로도 차단). +> 도커 실행 시 compose 가 읽기 전용 마운트하므로, **`docker compose up` 전에 config.toml 파일이 반드시 존재해야 합니다** +> (없이 up 하면 docker 가 같은 이름의 디렉터리를 만들어 기동에 실패합니다). + ### STEP 3-A — 도커로 실행 (운영 권장) ```bash @@ -127,7 +131,7 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/ -q # 전부 passed 기대( ### 로그 한 줄의 구조 ``` -2026-07-02 16:45:12+0900 INFO anchoring [batch 20260702-164512] 조정 company=f23c… type=1 bracket=10 n=13 성공=8 10‰→30‰ adj_id=32 +2026-07-02 16:45:12+0900 INFO anchoring [batch 20260702-164512] 조정 company=f23c… type=1 price_range=10 n=13 성공=8 10‰→30‰ adj_id=32 └──── 시각(항상 KST) ──┘ └레벨┘ └── 회차 태그 ──────┘ └──────────────── 내용 (key=value 형식) ────────────────┘ ``` @@ -175,7 +179,7 @@ docker logs anchoring | tail -20 # 최근 상태 | **예행 연습** (DB/Redis 무변경, 예상 결과만 로그) | `docker exec anchoring python -m anchoring.main --once --dry-run` — 첫 운영 실행 전 필수 권장 | | 재기동 | `docker compose restart anchoring` | | 서비스 중지/시작 | `docker compose stop` / `docker compose up -d` | -| 설정 변경 반영 | config.toml 수정 → `docker compose up -d --build` | +| 설정 변경 반영 | config.toml 수정 → `docker compose restart anchoring` (파일은 마운트라 리빌드 불필요) | | 다음 실행 예정 시각 확인 | `docker logs anchoring \| grep "다음 실행 예정"` | | 로그 레벨 올리기(디버깅) | env `LOG_LEVEL=debug` 로 재기동 | @@ -185,10 +189,11 @@ docker logs anchoring | tail -20 # 최근 상태 |---|---|---| | 기동 실패 + `BaseTableError: 정적 테이블 …` | `resources/anchoring_base.json` 손상/수정됨 | **의도된 안전장치** — git 으로 파일 원복 후 재기동. 이 파일은 절대 수정 금지 | | 기동 실패 + DB 연결 예외 | config.toml/env 의 DB 접속 정보 오류 | 접속 정보 확인, `psql` 로 직접 접속 테스트 | +| 기동 실패 + `config.toml` 이 **디렉터리**로 생겨 있음 | config.toml 없이 `docker compose up` — 마운트 대상이 없어 docker 가 디렉터리를 만듦 | `docker compose down` → 디렉터리 삭제 → `cp config.toml.example config.toml` 채우고 재기동 | | `[redis] GET/SET 실패 … DB 폴백` WARN | Redis 다운/네트워크 | **서비스는 계속 정상 동작**(DB 폴백). `docker compose up -d anchoring-redis` 로 복구하면 다음 실행 때 캐시 자동 재적재 | | `redis 실패 누계 get=… set=…` WARN | 위와 동일(회차 요약) | 위와 동일 | | `[redis] 범위 밖 캐시 값 무시(오염 의심)` WARN | 누군가/다른 프로세스가 Redis 에 비정상 값을 씀 | 동작엔 문제 없음(자동 무시 + DB 폴백 + 재적재로 자가 교정). 반복되면 Redis 접근 경로 점검 — 포트가 외부에 열려 있지 않은지(`127.0.0.1` 바인딩) 확인 | -| `가격 제시 흔적 0%` WARN | backend 의 가격 기록 배선이 끊김(배포 사고 등) — 학습이 조용히 멈추는 신호 | backend 팀에 `chat_service` 의 `last_offered_price` 갱신 경로 점검 요청 | +| `가격 제시 흔적 0%` WARN | backend 의 가격 기록 배선이 끊김(배포 사고 등) — 학습이 조용히 멈추는 신호 | backend 팀에 `chat_service` 의 `last_offer_price` 갱신 경로 점검 요청 | | `칸 평가 실패 company=…` ERROR | 해당 칸 DB 오류/마킹 경합 | 스택 확인. 실패 칸은 마킹되지 않아 **다음 회차 자동 재시도** — 같은 칸이 연속 실패하면 개발 팀 문의 | | `박제 정합 불일치 n건` WARN | negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) | negodata 팀에 `docs/인수인계.md` §1.3 정수식 적용 여부 점검 요청 | | 종료 요약이 WARNING (`failed_cells > 0`) | 일부 칸 실패 | 바로 위 ERROR 라인들 확인 | @@ -201,35 +206,35 @@ docker logs anchoring | tail -20 # 최근 상태 ```sql -- ① 어떤 회사의 값 변천사 (시간순) — 이전 값→새 값·변화폭·성공률까지 한 줄에 -SELECT * FROM anchoring.rate_history +SELECT * FROM anchoring.value_history WHERE company_id = '' ORDER BY adjustment_id; -- ①-b 어떤 회사의 칸별 "현재값" 한눈에 (여기 없는 칸 = 시작값 1%) -SELECT * FROM anchoring.current_rates +SELECT * FROM anchoring.current_values WHERE company_id = ''; -- ② 특정 조정(adj_id)의 근거가 된 협상들 -SELECT s.session_id, s.status, s.target_anchoring_price, s.last_offered_price, s.bid_price +SELECT s.session_id, s.status, s.anchoring_price, s.last_offer_price, s.bid_price FROM negotiation.sessions s WHERE s.session_id IN ( - SELECT jsonb_array_elements_text(consumed_session_ids)::uuid - FROM anchoring.rate_adjustments WHERE id = + SELECT jsonb_array_elements_text(used_session_ids)::uuid + FROM anchoring.adjustments WHERE id = ); -- ③ 특정 협상이 어느 조정에 채점됐나 -SELECT anchoring_adjustment_id FROM negotiation.sessions WHERE session_id = ''; +SELECT used_by_adjustment_id FROM negotiation.sessions WHERE session_id = ''; -- NULL = 아직 채점 전(다음 회차로 이월) / 0 = 채점 제외 확정 / 숫자 = 해당 조정 id → ② 로 ``` -로그의 `adj_id=32` ↔ DB 의 `rate_adjustments.id=32` 가 같은 것을 가리킵니다. +로그의 `adj_id=32` ↔ DB 의 `adjustments.id=32` 가 같은 것을 가리킵니다. ## 9. 절대 하면 안 되는 것 이 시스템의 신뢰성은 "기록이 불변"이라는 전제 위에 서 있습니다 (상세 근거: `개발용.md` §12). -- ❌ `anchoring.rate_adjustments` 행을 **UPDATE/DELETE** — 조정 이력은 유일한 진실 원천 -- ❌ `sessions` 의 `target_anchoring_price` / `anchor_rate_permille` 수동 수정 — 채점 근거가 오염됨 +- ❌ `anchoring.adjustments` 행을 **UPDATE/DELETE** — 조정 이력은 유일한 진실 원천 +- ❌ `sessions` 의 `anchoring_price` / `anchoring_value` 수동 수정 — 채점 근거가 오염됨 - ❌ `resources/anchoring_base.json`(기준표) 수정 — 검증 실패로 기동이 막히며, 값 변경은 정책 재확정 사안 - ❌ 상수(조정폭 δ, 경계 60/30, 상·하한, 10건 임계, 배치 주기) 임의 변경 — 전부 정책 고정값 - ❌ anchoring 컨테이너를 **2개 이상 동시 실행** — 중복 조정 방지 장치(롤백)가 막아주긴 하지만 설계상 단일 인스턴스가 원칙 @@ -246,4 +251,4 @@ docker logs anchoring | grep "다음 실행 예정" # ③ (재기 - ① 이 비어 있고 ② 가 `'status': 'done'` 이면 끝. - `carryover_cells`(이월)가 계속 크기만 하고 `evaluated_cells` 가 0인 상태가 몇 달 지속되면 거래량 자체가 적은 것 — 장애가 아니라 정책 검토(희소 칸 과제, `기획용.md` FAQ) 대상입니다. -- 분기에 한 번쯤: 조정 이력 백업이 DB 백업 정책에 포함돼 있는지 확인 (`rate_adjustments` 는 영구 보존 대상). +- 분기에 한 번쯤: 조정 이력 백업이 DB 백업 정책에 포함돼 있는지 확인 (`adjustments` 는 영구 보존 대상). diff --git a/schedules/anchoring/docs/워크플로우.md b/schedules/anchoring/docs/워크플로우.md index 33a97a3..e27f35c 100644 --- a/schedules/anchoring/docs/워크플로우.md +++ b/schedules/anchoring/docs/워크플로우.md @@ -17,7 +17,7 @@ |---|---|---| | **기준표** (정적 기본 테이블) | 공장 출하 시 기본 설정값 | 모든 칸의 출발점(전부 1%). 절대 안 바뀌는 내장 표 | | **협상 기록** (`negotiation.sessions`) | 협상 한 건 한 건의 계약서 철 | "그때 기준가가 얼마였고, 상대가 얼마를 써냈고, 얼마에 끝났는지"가 적힘 | -| **조정 장부** (`anchoring.rate_adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 | +| **조정 장부** (`anchoring.adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 | | **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 | 여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대** 조합입니다. 가격대는 자릿수 단위 사다리(1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대, 총 46칸)로 나뉘고, 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다. @@ -44,7 +44,7 @@ ### ③ 협력사가 가격을 써낼 때 — "가격 흔적" -협력사가 협상 채팅에서 가격을 입력할 때마다, 그 **마지막 제시가가 협상 기록에 남습니다**(`last_offered_price`). +협력사가 협상 채팅에서 가격을 입력할 때마다, 그 **마지막 제시가가 협상 기록에 남습니다**(`last_offer_price`). 이게 중요한 이유: **가격을 써낸** 협상과 **한 번도 안 써낸** 협상은 정책적으로 완전히 다르게 취급하기 때문입니다. diff --git a/schedules/anchoring/docs/인수인계.md b/schedules/anchoring/docs/인수인계.md index dca76bd..35a600b 100644 --- a/schedules/anchoring/docs/인수인계.md +++ b/schedules/anchoring/docs/인수인계.md @@ -6,7 +6,7 @@ ## 배경 한 줄 -앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.rate_adjustments`(조정 이력) + Redis 캐시이며, **`schedules/anchoring` 자립 서비스**(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다. +앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.adjustments`(조정 이력) + Redis 캐시이며, **`schedules/anchoring` 자립 서비스**(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다. ## 전달물 (→ 각 담당자) @@ -14,7 +14,7 @@ |---|---| | `schedules/anchoring/src/anchoring/` 모듈 | `constants.py`(상수·enum) · `base_table.py`(정적 테이블 로더) · `service.py`(순수 계산 함수) · `redis_client.py` · `reader.py`(rate 조회) — **전부 async(SQLAlchemy async + redis.asyncio) 자립형이라 negodata 에 그대로 복사/이식 가능** | | `schedules/anchoring/src/anchoring/resources/anchoring_base.json` | 정적 기본 테이블 (46행 자릿수 사다리, 불변) | -| `schedules/anchoring/schema.sql` | `anchoring.rate_adjustments` 테이블 + `negotiation.sessions` 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의) | +| `schedules/anchoring/schema.sql` | `anchoring.adjustments` 테이블 + `negotiation.sessions` 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의) | | `schedules/anchoring/docker-compose.yml` | anchoring 서비스 + redis 동봉 — **negodata 는 이 redis 인스턴스를 바라본다** (`REDIS_HOST` 환경변수) | | 이 문서 | 적용 위치·변경 전후 명세 | @@ -24,10 +24,10 @@ > ✅ **적용 완료 (2026-07-04)** — negodata 담당자 승인 하에 backend 담당(민헌)이 이 절을 직접 적용했다. > -> - 이식 위치: `negodata/backend/common/anchoring/` (constants·base_table·service 는 읽기 경로 발췌, reader 는 이식판) + `_build_quotation` 앵커 산출 교체 + 재생성 앵커 상속 폐지 + `sessions.anchor_rate_permille` 모델 매핑 -> - **이식판 reader 는 Redis 캐시를 쓰지 않는다**: `anchoring.current_rates` 뷰 단일 쿼리 → 실패·무이력 시 정적 테이블 폴백. (2026-07-03 단순화 결정 — 조회가 견적 생성 시 1회뿐이라 캐시 불필요. 모듈 쪽 Redis 제거는 후속 백로그로 진행) -> - 검증: negodata 테스트 스위트 50종 통과 — 앵커링 신설 5종(스키마 부재 폴백·칸별 조정 반영·유형 미지정 폴백·재생성 앵커 재계산·`calc_bracket_index` 경계 골든 벡터) 포함 -> - 전환기 점프 확인(§3-③): 적용 시점 `rate_adjustments` 0건 → 점프 없음(시작값 10‰ = 구 기본 `anchoring_value` 0.01 과 동일) +> - 이식 위치: `negodata/backend/common/anchoring/` (constants·base_table·service 는 읽기 경로 발췌, reader 는 이식판) + `_build_quotation` 앵커 산출 교체 + 재생성 앵커 상속 폐지 + `sessions.anchoring_value` 모델 매핑 +> - **이식판 reader 는 Redis 캐시를 쓰지 않는다**: `anchoring.current_values` 뷰 단일 쿼리 → 실패·무이력 시 정적 테이블 폴백. (2026-07-03 단순화 결정 — 조회가 견적 생성 시 1회뿐이라 캐시 불필요. 모듈 쪽 Redis 제거는 후속 백로그로 진행) +> - 검증: negodata 테스트 스위트 50종 통과 — 앵커링 신설 5종(스키마 부재 폴백·칸별 조정 반영·유형 미지정 폴백·재생성 앵커 재계산·`calc_price_range_index` 경계 골든 벡터) 포함 +> - 전환기 점프 확인(§3-③): 적용 시점 `adjustments` 0건 → 점프 없음(시작값 10‰ = 구 기본 `anchoring_value` 0.01 과 동일) ### 1.1 변경 대상 @@ -54,15 +54,15 @@ else: # ① 칸 해석 # company_id = items.company_id (해당 상품의 소유 회사) # supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3) -# bracket = calc_bracket_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식 +# price_range = calc_price_range_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식 # ② rate 조회 — 전달받은 reader 모듈 사용 -rate = await get_anchor_rate(db, company_id, supplier_type, bracket) # db = AsyncSession -# 내부 동작: Redis GET → miss 시 anchoring.rate_adjustments 최신 행 → 없으면 정적 테이블(10‰) -# supplier_type ∉ {1,2,3} 이면 get_base_rate_permille(bracket) 사용 (정적 테이블 시작값) +value = await get_current_anchoring_value(db, company_id, supplier_type, price_range) # db = AsyncSession +# 내부 동작: Redis GET → miss 시 anchoring.adjustments 최신 행 → 없으면 정적 테이블(10‰) +# supplier_type ∉ {1,2,3} 이면 get_base_anchoring_value(price_range) 사용 (정적 테이블 시작값) # ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가) ap = tp * (1000 - rate) // 1000 # ④ 세션 INSERT 에 두 컬럼 모두 박제 -sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...) +sessions(..., anchoring_price=ap, anchoring_value=rate, ...) ``` ### 1.4 필수 규칙 @@ -71,7 +71,7 @@ sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...) 2. **정수 연산 MUST**: `tp * (1000 - rate) // 1000`. 부동소수점 곱셈(`int(tp * (1 - x))`, `round(...)`) 금지 — 1원 단위 내림의 정확성 보장. 3. **`quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다.** 컬럼 자체와 산정내역 화면 표기는 유지해도 된다(표시 정리는 선택). 4. **`quotations.supplier_type` 기록 유지**: 재협상 견적 생성 시 이 값이 채워져야 앵커링 집계가 유형별로 분류된다(NULL 이면 해당 세션은 학습에서 자동 제외). -5. **박제 후 수정 금지**: `sessions.target_anchoring_price` / `anchor_rate_permille` 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다. +5. **박제 후 수정 금지**: `sessions.anchoring_price` / `anchoring_value` 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다. 6. **Redis 장애 내성**: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다. ### 1.5 적용 전(전환기) 동작 @@ -82,18 +82,18 @@ sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...) ## 2. agent — **변경 없음** -앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offered_price`). +앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offer_price`). --- ## 3. 적용 순서 (권장) ``` -① DB 스키마 적용 (schedules/anchoring/schema.sql — rate_adjustments + sessions 컬럼 3개) +① DB 스키마 적용 (schedules/anchoring/schema.sql — adjustments + sessions 컬럼 3개) ② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작) + backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작) ③ 전환기 점프 확인 (negodata 적용 직전): - SELECT max(anchor_rate_permille) FROM anchoring.current_rates; + SELECT max(anchoring_value) FROM anchoring.current_values; — ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 rate 로 한 번에 이동한다 ("조정일당 한 계단" 원칙이 이 순간만 예외). 값이 크게 벌어져 있으면 점프 감수 여부 또는 이력 리셋을 정책 결정 후 진행. diff --git a/schedules/anchoring/migrations/20260706_rename_anchoring.sql b/schedules/anchoring/migrations/20260706_rename_anchoring.sql new file mode 100644 index 0000000..89dba86 --- /dev/null +++ b/schedules/anchoring/migrations/20260706_rename_anchoring.sql @@ -0,0 +1,112 @@ +-- ============================================================ +-- 앵커링 이름 전면 개편 마이그레이션 (2026-07-06) +-- rate_adjustments → adjustments (+컬럼 6종) +-- sessions 앵커링 컬럼 4종, 뷰 2종(rate_history/current_rates → value_history/current_values) +-- 대상: 구 이름 스키마가 이미 적용된 기존 DB (신규 DB 는 schema.sql 만 적용하면 됨) +-- 멱등: 각 rename 은 구 이름 존재 시에만 수행 — 재실행·부분 적용 상태에서도 안전 +-- 적용: psql -h -U -d negosium_db -f migrations/20260706_rename_anchoring.sql +-- ============================================================ +\connect negosium_db + +BEGIN; + +-- 1) 구 이름 뷰 제거 (신 이름 뷰는 마지막에 재생성 — 정의는 schema.sql 과 동일) +DROP VIEW IF EXISTS anchoring.rate_history; +DROP VIEW IF EXISTS anchoring.current_rates; + +-- 2) 테이블·시퀀스·인덱스 rename +DO $$ +BEGIN + IF EXISTS (SELECT FROM information_schema.tables + WHERE table_schema = 'anchoring' AND table_name = 'rate_adjustments') THEN + ALTER TABLE anchoring.rate_adjustments RENAME TO adjustments; + END IF; + IF EXISTS (SELECT FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'anchoring' AND c.relname = 'rate_adjustments_id_seq') THEN + ALTER SEQUENCE anchoring.rate_adjustments_id_seq RENAME TO adjustments_adjustment_id_seq; + END IF; + IF EXISTS (SELECT FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'anchoring' AND c.relname = 'idx_rate_adjustments_cell') THEN + ALTER INDEX anchoring.idx_rate_adjustments_cell RENAME TO idx_adjustments_cell; + END IF; + -- 테이블 rename 은 PK 제약 이름을 바꾸지 않는다 — 함께 정리 + IF EXISTS (SELECT FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace + WHERE n.nspname = 'anchoring' AND con.conname = 'rate_adjustments_pkey') THEN + ALTER TABLE anchoring.adjustments RENAME CONSTRAINT rate_adjustments_pkey TO adjustments_pkey; + END IF; +END $$; + +-- 3) adjustments 컬럼 rename +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + SELECT * FROM (VALUES + ('id', 'adjustment_id'), + ('price_bracket_index', 'price_range_index'), + ('nego_count', 'sample_count'), + ('anchor_rate_before', 'anchoring_value_before'), + ('anchor_rate_after', 'anchoring_value_after'), + ('consumed_session_ids', 'used_session_ids') + ) AS m(old_name, new_name) + LOOP + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_schema = 'anchoring' AND table_name = 'adjustments' + AND column_name = r.old_name) THEN + EXECUTE format('ALTER TABLE anchoring.adjustments RENAME COLUMN %I TO %I', + r.old_name, r.new_name); + END IF; + END LOOP; +END $$; + +-- 4) negotiation.sessions 앵커링 컬럼 rename +-- (부분 인덱스 idx_sessions_anchoring_pending 술어는 컬럼 rename 을 자동 추종 — 재생성 불필요) +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + SELECT * FROM (VALUES + ('target_anchoring_price', 'anchoring_price'), + ('anchor_rate_permille', 'anchoring_value'), + ('last_offered_price', 'last_offer_price'), + ('anchoring_adjustment_id','used_by_adjustment_id') + ) AS m(old_name, new_name) + LOOP + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_schema = 'negotiation' AND table_name = 'sessions' + AND column_name = r.old_name) THEN + EXECUTE format('ALTER TABLE negotiation.sessions RENAME COLUMN %I TO %I', + r.old_name, r.new_name); + END IF; + END LOOP; +END $$; + +-- 5) 신 이름 뷰 재생성 (schema.sql §조회용 뷰와 동일 정의) +CREATE OR REPLACE VIEW anchoring.value_history AS +SELECT adjustment_id, + company_id, + supplier_type, + price_range_index, + anchoring_value_before, + anchoring_value_after, + anchoring_value_after - anchoring_value_before AS value_change, + sample_count, + success_count, + round(success_count::numeric / sample_count, 3) AS success_rate, + created_at +FROM anchoring.adjustments; + +CREATE OR REPLACE VIEW anchoring.current_values AS +SELECT DISTINCT ON (company_id, supplier_type, price_range_index) + company_id, + supplier_type, + price_range_index, + anchoring_value_after AS anchoring_value, + adjustment_id AS last_adjustment_id, + created_at AS last_adjusted_at +FROM anchoring.adjustments +ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC; + +COMMIT; diff --git a/schedules/anchoring/schema.sql b/schedules/anchoring/schema.sql index af067d9..4a309fb 100644 --- a/schedules/anchoring/schema.sql +++ b/schedules/anchoring/schema.sql @@ -4,69 +4,70 @@ -- 신규 DB 구축 순서: postgres-init/01~04 → 이 파일 -- 규범: docs/개발용.md §6. IF NOT EXISTS 라 재적용 안전. -- 컨벤션: FK/CHECK/PG ENUM 없음, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). +-- 구 이름(rate_adjustments 등)에서 넘어오는 기존 DB 는 migrations/20260706_rename_anchoring.sql 적용. -- ============================================================ \connect negosium_db CREATE SCHEMA IF NOT EXISTS anchoring; -- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략. -CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments ( - id BIGSERIAL PRIMARY KEY, - company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래) - supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15) - price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장) - nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장) - success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수 - anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값) - anchor_rate_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장 - consumed_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사) - created_at TIMESTAMPTZ NOT NULL DEFAULT now() +CREATE TABLE IF NOT EXISTS anchoring.adjustments ( + adjustment_id BIGSERIAL PRIMARY KEY, + company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래) + supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15) + price_range_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장) + sample_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장) + success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수 + anchoring_value_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값) + anchoring_value_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장 + used_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사) + created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- 현재 값 조회 최적화: 칸별 최신 조정 -CREATE INDEX IF NOT EXISTS idx_rate_adjustments_cell - ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC); +CREATE INDEX IF NOT EXISTS idx_adjustments_cell + ON anchoring.adjustments (company_id, supplier_type, price_range_index, adjustment_id DESC); --- 선행요건 §13 + 소비 마킹. target_anchoring_price 는 기존 컬럼(negodata 가 생성 시 박제). +-- 선행요건 §13 + 소비 마킹. anchoring_price 는 기존 컬럼(negodata 가 생성 시 박제). ALTER TABLE negotiation.sessions - ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 rate(‰) 박제 - ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 가격 입력마다 backend 가 갱신, 종료 후 불변. NULL=가격 흔적 없음(표본 제외) - ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id + ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(‰) 박제 + ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 가격 입력마다 backend 가 갱신, 종료 후 불변. NULL=가격 흔적 없음(표본 제외) + ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id -- 배치 스캔 최적화: 미처리 "재협상" 세션만 (부분 인덱스). -- qt_type=1 을 술어에 포함해야 함 — 빼면 배치가 마킹하지 않는 비재협상 세션이 -- 영구 잔류해 인덱스가 전체 세션 수에 비례해 성장한다(의도는 이월 풀만 담는 소형 인덱스). CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending ON negotiation.sessions (status) - WHERE anchoring_adjustment_id IS NULL AND deleted = false AND qt_type = 1; + WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1; -- ============================================================ --- 조회용 뷰 (파생 — 상태 없음, 진실 원천은 rate_adjustments) +-- 조회용 뷰 (파생 — 상태 없음, 진실 원천은 adjustments) -- ============================================================ -- 회사별 앵커링 값 변경 이력 리스트업: "언제, 어떤 칸이, 몇 건 중 몇 건 성공으로, 몇 ‰에서 몇 ‰로" -CREATE OR REPLACE VIEW anchoring.rate_history AS -SELECT id AS adjustment_id, +CREATE OR REPLACE VIEW anchoring.value_history AS +SELECT adjustment_id, company_id, - supplier_type, -- 1유통/2제조/3총판 - price_bracket_index, -- 0..45 자릿수 사다리 - anchor_rate_before, -- 이전 값(‰) - anchor_rate_after, -- 새 값(‰) - anchor_rate_after - anchor_rate_before AS delta_permille, - nego_count, + supplier_type, -- 1유통/2제조/3총판 + price_range_index, -- 0..45 자릿수 사다리 + anchoring_value_before, -- 이전 값(‰) + anchoring_value_after, -- 새 값(‰) + anchoring_value_after - anchoring_value_before AS value_change, + sample_count, success_count, - round(success_count::numeric / nego_count, 3) AS success_rate, + round(success_count::numeric / sample_count, 3) AS success_rate, created_at -FROM anchoring.rate_adjustments; +FROM anchoring.adjustments; -- 칸별 현재값: 칸의 최신 조정 행. 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰) -CREATE OR REPLACE VIEW anchoring.current_rates AS -SELECT DISTINCT ON (company_id, supplier_type, price_bracket_index) +CREATE OR REPLACE VIEW anchoring.current_values AS +SELECT DISTINCT ON (company_id, supplier_type, price_range_index) company_id, supplier_type, - price_bracket_index, - anchor_rate_after AS anchor_rate_permille, - id AS last_adjustment_id, - created_at AS last_adjusted_at -FROM anchoring.rate_adjustments -ORDER BY company_id, supplier_type, price_bracket_index, id DESC; + price_range_index, + anchoring_value_after AS anchoring_value, + adjustment_id AS last_adjustment_id, + created_at AS last_adjusted_at +FROM anchoring.adjustments +ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC; diff --git a/schedules/anchoring/src/anchoring/base_table.py b/schedules/anchoring/src/anchoring/base_table.py index 3b41b32..0a8cabf 100644 --- a/schedules/anchoring/src/anchoring/base_table.py +++ b/schedules/anchoring/src/anchoring/base_table.py @@ -6,11 +6,11 @@ DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증 import json from pathlib import Path -from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS +from anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS _RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json" -_rates: list[int] | None = None # bracket_index → 시작값(‰) +_values: list[int] | None = None # price_range_index → 시작값(‰) class BaseTableError(RuntimeError): @@ -22,9 +22,9 @@ def _validate(rows: list) -> list[int]: 규약(§2.1): 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20. """ - if not isinstance(rows, list) or len(rows) != BRACKET_COUNT: - raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}") - rates: list[int] = [] + if not isinstance(rows, list) or len(rows) != PRICE_RANGE_COUNT: + raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {PRICE_RANGE_COUNT}") + values: list[int] = [] for i, row in enumerate(rows): idx = row.get("idx") ub = row.get("upper_bound") @@ -35,24 +35,24 @@ def _validate(rows: list) -> list[int]: raise BaseTableError(f"upper_bound 사다리 불일치: idx={idx} upper_bound={ub} (기대 {UPPER_BOUNDS[i]})") if not isinstance(av, (int, float)) or av != av or not (0.01 <= av <= 0.20): raise BaseTableError(f"anchoring_value 범위 밖: idx={idx} value={av}") - rates.append(int(round(av * 1000))) - return rates + values.append(int(round(av * 1000))) + return values def load_base_table() -> None: """리소스 파일 로드 + 검증. 기동 시 1회 호출(멱등).""" - global _rates - if _rates is not None: + global _values + if _values is not None: return try: rows = json.loads(_RESOURCE.read_text()) except Exception as ex: raise BaseTableError(f"정적 테이블 파일 로드 실패: {_RESOURCE}: {ex}") from ex - _rates = _validate(rows) + _values = _validate(rows) -def get_base_rate_permille(bracket_index: int) -> int: +def get_base_anchoring_value(price_range_index: int) -> int: """구간 인덱스 → 시작 앵커링 값(‰). §2.1""" - if _rates is None: + if _values is None: load_base_table() - return _rates[bracket_index] + return _values[price_range_index] diff --git a/schedules/anchoring/src/anchoring/batch.py b/schedules/anchoring/src/anchoring/batch.py index c24b263..0511d1c 100644 --- a/schedules/anchoring/src/anchoring/batch.py +++ b/schedules/anchoring/src/anchoring/batch.py @@ -11,10 +11,8 @@ from zoneinfo import ZoneInfo from sqlalchemy import select, update -from anchoring.base_table import get_base_rate_permille +from anchoring.base_table import get_base_anchoring_value from anchoring.constants import ( - ANCHOR_RATE_MAX, - ANCHOR_RATE_MIN, EVAL_WEEK_PARITY, MARK_EXCLUDED, QT_TYPE_RENEGO, @@ -26,10 +24,10 @@ from anchoring.constants import ( ) from anchoring.db import session_scope from anchoring.log import LOG -from anchoring.models import Item, Quotation, RateAdjustment, Session -from anchoring.reader import get_latest_adjusted_rate -from anchoring.redis_client import consume_failure_counts, ping, set_rate -from anchoring.service import calc_bracket_index, evaluate_pending, judge_sample_type +from anchoring.models import Item, Quotation, Adjustment, Session +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 KST = ZoneInfo("Asia/Seoul") _MARK_CHUNK = 1000 @@ -45,34 +43,34 @@ def is_evaluation_week(now_kst: datetime) -> bool: async def _reconcile_cache() -> int: - """절차 0.5 — 조정 이력 보유 칸 전체의 최신 rate 를 Redis 일괄 re-SET. + """절차 0.5 — 조정 이력 보유 칸 전체의 최신 anchoring_value 를 Redis 일괄 re-SET. stale 키는 미스가 나지 않으므로(TTL 전까지) 매주 이걸로 회복한다(§7). """ stmt = ( select( - RateAdjustment.company_id, - RateAdjustment.supplier_type, - RateAdjustment.price_bracket_index, - RateAdjustment.anchor_rate_after, + Adjustment.company_id, + Adjustment.supplier_type, + Adjustment.price_range_index, + Adjustment.anchoring_value_after, ) .distinct( - RateAdjustment.company_id, - RateAdjustment.supplier_type, - RateAdjustment.price_bracket_index, + Adjustment.company_id, + Adjustment.supplier_type, + Adjustment.price_range_index, ) .order_by( - RateAdjustment.company_id, - RateAdjustment.supplier_type, - RateAdjustment.price_bracket_index, - RateAdjustment.id.desc(), + Adjustment.company_id, + Adjustment.supplier_type, + Adjustment.price_range_index, + Adjustment.adjustment_id.desc(), ) ) async with session_scope() as db: rows = (await db.execute(stmt)).all() ok = 0 - for company_id, stype, bracket, rate in rows: - if await set_rate(company_id, stype, bracket, rate): + for company_id, stype, price_range, value in rows: + if await set_value(company_id, stype, price_range, value): ok += 1 return ok @@ -90,9 +88,9 @@ async def _scan_pending(db, company_ids: list | None = None) -> list: Session.status, Session.bid_price, Session.target_price, - Session.target_anchoring_price, - Session.anchor_rate_permille, - Session.last_offered_price, + Session.anchoring_price, + Session.anchoring_value, + Session.last_offer_price, Quotation.supplier_type, Item.company_id, ) @@ -107,7 +105,7 @@ async def _scan_pending(db, company_ids: list | None = None) -> list: isouter=True, ) .where( - Session.anchoring_adjustment_id.is_(None), + Session.used_by_adjustment_id.is_(None), Session.deleted.is_(False), Session.qt_type == QT_TYPE_RENEGO, Session.status.in_(TERMINAL_SESSION_STATUSES), @@ -125,15 +123,15 @@ async def _mark_sessions(db, session_ids: list, adjustment_id: int) -> int: chunk = session_ids[i:i + _MARK_CHUNK] res = await db.execute( update(Session) - .where(Session.session_id.in_(chunk), Session.anchoring_adjustment_id.is_(None)) - .values(anchoring_adjustment_id=adjustment_id) + .where(Session.session_id.in_(chunk), Session.used_by_adjustment_id.is_(None)) + .values(used_by_adjustment_id=adjustment_id) .execution_options(synchronize_session=False) ) marked += res.rowcount return marked -async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples: list, +async def _evaluate_cell(company_id, supplier_type: int, price_range: int, samples: list, dry_run: bool = False) -> dict | None: """칸 1개 평가 — 조정 INSERT + 소비 마킹을 같은 세션 한 트랜잭션으로(§8 MUST). @@ -145,47 +143,50 @@ async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples: sample_types = [st for _, st in samples] async with session_scope() as db: - latest = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket) - rate_before = latest if latest is not None else get_base_rate_permille(bracket) - rate_after = evaluate_pending(rate_before, sample_types, supplier_type) - if rate_after is None: # 방어적 재확인(호출측에서 n>=10 보장) + latest = await get_latest_adjusted_value(db, company_id, supplier_type, price_range) + value_before = latest if latest is not None else get_base_anchoring_value(price_range) + evaluated = evaluate_samples(value_before, sample_types, supplier_type) + if evaluated is None: # 방어적 재확인(호출측에서 n>=10 보장) return None + value_after, clamped = evaluated if dry_run: return { "adjustment_id": None, - "before": rate_before, - "after": rate_after, + "before": value_before, + "after": value_after, + "clamped": clamped, "success": sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value), "n": len(samples), } - adjustment = RateAdjustment( + adjustment = Adjustment( company_id=company_id, supplier_type=supplier_type, - price_bracket_index=bracket, - nego_count=len(samples), + price_range_index=price_range, + sample_count=len(samples), success_count=sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value), - anchor_rate_before=rate_before, - anchor_rate_after=rate_after, - consumed_session_ids=[str(sid) for sid in session_ids], + anchoring_value_before=value_before, + anchoring_value_after=value_after, + used_session_ids=[str(sid) for sid in session_ids], ) db.add(adjustment) - await db.flush() # adjustment.id 확보 + await db.flush() # adjustment_id 확보 - marked = await _mark_sessions(db, session_ids, adjustment.id) + marked = await _mark_sessions(db, session_ids, adjustment.adjustment_id) if marked != len(session_ids): # 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백 raise MarkingConflictError( - f"company={company_id} type={supplier_type} bracket={bracket} 마킹 {marked}/{len(session_ids)}" + f"company={company_id} type={supplier_type} price_range={price_range} 마킹 {marked}/{len(session_ids)}" ) # 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복) - await set_rate(company_id, supplier_type, bracket, rate_after) + await set_value(company_id, supplier_type, price_range, value_after) return { - "adjustment_id": adjustment.id, - "before": rate_before, - "after": rate_after, + "adjustment_id": adjustment.adjustment_id, + "before": value_before, + "after": value_after, + "clamped": clamped, "success": adjustment.success_count, "n": len(samples), } @@ -205,7 +206,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N 첫 운영 실행 전 "이번 회차에 무슨 일이 일어날지" 확인용(§8 런북). 로그 규약: 모든 라인에 `[batch {run_id}]` 태그(회차 grep), 칸/회사 단위 라인은 - `company=` `type=` `bracket=` key=value 형식(회사별 grep — `grep company=`). + `company=` `type=` `price_range=` key=value 형식(회사별 grep — `grep company=`). """ now = datetime.now(KST) run_id = now.strftime("%Y%m%d-%H%M%S") @@ -240,14 +241,14 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N priced = 0 snapshot_mismatch = [] for r in rows: - if r.last_offered_price is not None: + if r.last_offer_price is not None: priced += 1 - # 박제 정합 감시: negodata 가 rate 와 anchor 를 함께 박제하기 시작하면(인수인계 적용 후) - # 정수식 tp*(1000-rate)//1000 과 박제 anchor 가 일치해야 한다 — 불일치 = 이식 오류 신호. - # 전환기(rate 미박제 = NULL)에는 자동 스킵된다. - if (r.anchor_rate_permille is not None and r.target_anchoring_price is not None + # 박제 정합 감시: negodata 가 anchoring_value 와 anchoring_price 를 함께 박제하기 시작하면(인수인계 적용 후) + # 정수식 tp*(1000-value)//1000 과 박제 anchoring_price 가 일치해야 한다 — 불일치 = 이식 오류 신호. + # 전환기(value 미박제 = NULL)에는 자동 스킵된다. + if (r.anchoring_value is not None and r.anchoring_price is not None and r.target_price is not None - and r.target_price * (1000 - r.anchor_rate_permille) // 1000 != r.target_anchoring_price): + and calc_anchoring_price(r.target_price, r.anchoring_value) != r.anchoring_price): snapshot_mismatch.append(r.session_id) if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None: excluded_ids.append(r.session_id) # 칸 구성 불가 @@ -257,24 +258,24 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N sample_type = judge_sample_type( is_done=r.status == SESSION_STATUS_DONE, bid_price=r.bid_price, - last_offered_price=r.last_offered_price, - anchor_price=r.target_anchoring_price, + last_offer_price=r.last_offer_price, + anchoring_price=r.anchoring_price, ) if sample_type == AnchoringSampleType.EXCLUDED.value: excluded_ids.append(r.session_id) per_company[str(r.company_id)]["excluded"] += 1 continue - bracket = calc_bracket_index(r.target_price) - cells[(r.company_id, r.supplier_type, bracket)].append((r.session_id, sample_type)) + price_range = calc_price_range_index(r.target_price) + cells[(r.company_id, r.supplier_type, price_range)].append((r.session_id, sample_type)) if snapshot_mismatch: sample = ", ".join(str(sid) for sid in snapshot_mismatch[:5]) LOG.warning(f"{tag} 박제 정합 불일치 {len(snapshot_mismatch)}건 — negodata 앵커 산출 이식 오류 의심 " f"(정수식과 박제 anchor 불일치). 예: {sample}") - # 가격 제시율 — backend 의 last_offered_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5) + # 가격 제시율 — backend 의 last_offer_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5) if rows and priced == 0: - LOG.warning(f"{tag} 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offered_price 전무) " + LOG.warning(f"{tag} 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offer_price 전무) " f"— backend 가격 입력 기록 배선 점검 필요") # 절차 2 — 제외 확정 마킹(재스캔 방지). 청크별 개별 커밋 — 판정이 결정적이라 @@ -293,18 +294,18 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N # 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음) evaluated = up = hold = down = clamped = failed = 0 carryover = 0 - for (company_id, stype, bracket), samples in cells.items(): + for (company_id, stype, price_range), samples in cells.items(): agg = per_company[str(company_id)] if len(samples) < SAMPLE_THRESHOLD: carryover += 1 # 마킹하지 않음 = 이월(§4.4) agg["carryover"] += 1 continue try: - result = await _evaluate_cell(company_id, stype, bracket, samples, dry_run=dry_run) + result = await _evaluate_cell(company_id, stype, price_range, samples, dry_run=dry_run) except Exception as ex: failed += 1 agg["failed"] += 1 - LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} bracket={bracket}: {ex}", exc_info=True) + LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} price_range={price_range}: {ex}", exc_info=True) continue if result is None: carryover += 1 @@ -314,7 +315,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N agg["evaluated"] += 1 # 칸별 조정 상세 — 로그만으로 "어느 칸이 왜 바뀌었나" 추적 + DB(adj_id) 교차 확인 label = "조정예정" if dry_run else "조정" - LOG.info(f"{tag} {label} company={company_id} type={stype} bracket={bracket} " + LOG.info(f"{tag} {label} company={company_id} type={stype} price_range={price_range} " f"n={result['n']} 성공={result['success']} {result['before']}‰→{result['after']}‰ " f"adj_id={result['adjustment_id']}") if result["after"] > result["before"]: @@ -326,7 +327,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N else: hold += 1 agg["hold"] += 1 - if result["after"] in (ANCHOR_RATE_MIN, ANCHOR_RATE_MAX): + if result["clamped"]: clamped += 1 # 회사별 요약 — 멀티테넌트 운영에서 테넌트 단위 상태를 한 줄로 diff --git a/schedules/anchoring/src/anchoring/constants.py b/schedules/anchoring/src/anchoring/constants.py index bf9abb2..caaaa97 100644 --- a/schedules/anchoring/src/anchoring/constants.py +++ b/schedules/anchoring/src/anchoring/constants.py @@ -7,13 +7,13 @@ backend 를 import 하지 않고 자체 보유한다(자립 모듈). 코드값 from enum import Enum # ── 앵커링 값(정수 천분율 ‰) ────────────────────────────── -ANCHOR_RATE_MIN = 10 # 하한 1% -ANCHOR_RATE_MAX = 200 # 상한 20% +ANCHORING_VALUE_MIN = 10 # 하한 1% +ANCHORING_VALUE_MAX = 200 # 상한 20% # 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2) # 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드 # ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름) -DELTA_PERMILLE = { +ADJUSTMENT_STEP = { 1: 20, # 유통(DISTRIBUTION) ±2% 2: 10, # 제조(MANUFACTURE) ±1% 3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5% @@ -36,13 +36,13 @@ def _build_upper_bounds() -> tuple: UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개 -BRACKET_COUNT = len(UPPER_BOUNDS) # 46 -BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45 +PRICE_RANGE_COUNT = len(UPPER_BOUNDS) # 46 +PRICE_RANGE_INDEX_MAX = PRICE_RANGE_COUNT - 1 # 45 # ── 배치 ────────────────────────────────────────────────── EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에 # 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유) -MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값 (BIGSERIAL 은 1부터라 충돌 없음) +MARK_EXCLUDED = 0 # sessions.used_by_adjustment_id 제외 확정 마킹값 (BIGSERIAL 은 1부터라 충돌 없음) # ── Redis 캐시 (§7) ────────────────────────────────────── CACHE_TTL_SECONDS = 7 * 24 * 3600 # stale 잔존 방지 보조(주 방어선은 주간 re-SET) @@ -50,7 +50,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백 class SupplierType(Enum): - """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type + """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type (negodata SupplierType 과 동일 코드)""" NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외) DISTRIBUTION = 1 # 유통 diff --git a/schedules/anchoring/src/anchoring/main.py b/schedules/anchoring/src/anchoring/main.py index 275de9c..8d6b1f1 100644 --- a/schedules/anchoring/src/anchoring/main.py +++ b/schedules/anchoring/src/anchoring/main.py @@ -9,6 +9,7 @@ 기동 시 정적 테이블 검증 실패 → 예외로 즉시 중단(§13-7 MUST). """ +import argparse import asyncio import signal import sys @@ -16,27 +17,42 @@ import sys from anchoring.base_table import load_base_table from anchoring.batch import run_evaluation_batch from anchoring.config import load_config -from anchoring.constants import BRACKET_COUNT +from anchoring.constants import PRICE_RANGE_COUNT from anchoring.db import dispose_engine, init_engine from anchoring.log import LOG, configure from anchoring.redis_client import close_redis, init_redis from anchoring.scheduler import build_scheduler -async def _run(once: bool) -> None: +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="python -m anchoring.main", + description="앵커링 격주 조정 배치 — 기본은 스케줄러 상주", + ) + parser.add_argument("--once", action="store_true", + help="수동 1회 실행(격주 게이트 무시) 후 종료") + parser.add_argument("--dry-run", action="store_true", + help="판정·예상 조정을 로그로만 — DB/Redis 무변경 (--once 필수)") + args = parser.parse_args(argv) + # 상주 모드에는 dry-run 이 없다 — 무시된 채 실제 변경 스케줄러가 뜨는 사고를 기동 전에 차단 + if args.dry_run and not args.once: + parser.error("--dry-run 은 --once 와 함께만 쓸 수 있습니다") + return args + + +async def _run(once: bool, dry_run: bool = False) -> None: cfg = load_config() configure(cfg.log_level) load_base_table() # 검증 실패 시 BaseTableError → 기동 중단 - LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({BRACKET_COUNT}칸 사다리)") + LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({PRICE_RANGE_COUNT}칸 사다리)") init_engine(cfg) init_redis(cfg.redis) try: if once: - dry = "--dry-run" in sys.argv - LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry else ''})") - result = await run_evaluation_batch(force=True, dry_run=dry) + LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry_run else ''})") + result = await run_evaluation_batch(force=True, dry_run=dry_run) LOG.info(f"[main] 결과: {result}") return result @@ -60,7 +76,8 @@ async def _run(once: bool) -> None: def main() -> None: - result = asyncio.run(_run(once="--once" in sys.argv)) + args = _parse_args() + result = asyncio.run(_run(once=args.once, dry_run=args.dry_run)) # --once 가 부분 실패(partial)로 끝나면 비정상 종료코드 — 런북/cron 에서 감지 가능해야 한다 if result is not None and result.get("status") not in ("done", "skipped", "dry_run"): sys.exit(1) diff --git a/schedules/anchoring/src/anchoring/models.py b/schedules/anchoring/src/anchoring/models.py index 9695d71..10ce3cd 100644 --- a/schedules/anchoring/src/anchoring/models.py +++ b/schedules/anchoring/src/anchoring/models.py @@ -1,7 +1,7 @@ """ORM 모델 — 자립(backend models 미사용). -- 소유(쓰기): anchoring.rate_adjustments (append-only — UPDATE/DELETE 금지 §5) -- sessions 는 anchoring_adjustment_id 마킹만 쓰기 가능(그 외 컬럼 수정 금지 §12). +- 소유(쓰기): anchoring.adjustments (append-only — UPDATE/DELETE 금지 §5) +- sessions 는 used_by_adjustment_id 마킹만 쓰기 가능(그 외 컬럼 수정 금지 §12). quotations/items 는 읽기 전용 경량 매핑(집계에 필요한 컬럼만). """ from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text @@ -11,19 +11,19 @@ from sqlalchemy.orm import declarative_base BASE = declarative_base() -class RateAdjustment(BASE): - __tablename__ = "rate_adjustments" +class Adjustment(BASE): + __tablename__ = "adjustments" __table_args__ = {"schema": "anchoring"} - id = Column(BigInteger, primary_key=True, autoincrement=True) + adjustment_id = Column(BigInteger, primary_key=True, autoincrement=True) company_id = Column(UUID(as_uuid=True), nullable=False) supplier_type = Column(SmallInteger, nullable=False) # 1유통/2제조/3총판 - price_bracket_index = Column(Integer, nullable=False) # 0..45 (자릿수 사다리) - nego_count = Column(Integer, nullable=False) # 유효 표본 수 n + price_range_index = Column(Integer, nullable=False) # 0..45 (자릿수 사다리) + sample_count = Column(Integer, nullable=False) # 유효 표본 수 n success_count = Column(Integer, nullable=False) - anchor_rate_before = Column(SmallInteger, nullable=False) # ‰ - anchor_rate_after = Column(SmallInteger, nullable=False) # ‰, clamp [10,200] - consumed_session_ids = Column(JSONB, nullable=False) # 소비 세션 uuid 문자열 배열(창 박제) + anchoring_value_before = Column(SmallInteger, nullable=False) # ‰ + anchoring_value_after = Column(SmallInteger, nullable=False) # ‰, clamp [10,200] + used_session_ids = Column(JSONB, nullable=False) # 소비 세션 uuid 문자열 배열(창 박제) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) @@ -37,10 +37,10 @@ class Session(BASE): item_id = Column(UUID(as_uuid=True), nullable=False) qt_type = Column(SmallInteger, nullable=False) # 1=재협상 target_price = Column(BigInteger, nullable=False) - target_anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준) - anchor_rate_permille = Column(SmallInteger, nullable=True) # 박제 rate - last_offered_price = Column(BigInteger, nullable=True) # 마지막 제시가(가격 흔적 — NULL=표본 제외) - anchoring_adjustment_id = Column(BigInteger, nullable=True) # 소비 마킹(모듈이 쓰는 유일 컬럼) + anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준) + anchoring_value = Column(SmallInteger, nullable=True) # 박제 anchoring_value(‰) + last_offer_price = Column(BigInteger, nullable=True) # 마지막 제시가(가격 흔적 — NULL=표본 제외) + used_by_adjustment_id = Column(BigInteger, nullable=True) # 소비 마킹(모듈이 쓰는 유일 컬럼) status = Column(SmallInteger, nullable=False) # 3=DONE 4=NOT_PARTICIPATED 5=REJECTED bid_price = Column(BigInteger, nullable=True) deleted = Column(Boolean, nullable=False) diff --git a/schedules/anchoring/src/anchoring/reader.py b/schedules/anchoring/src/anchoring/reader.py index 797f9f2..0acc0bb 100644 --- a/schedules/anchoring/src/anchoring/reader.py +++ b/schedules/anchoring/src/anchoring/reader.py @@ -1,42 +1,42 @@ """현재 앵커링 값 조회(읽기 경로). 규범: §4.5, §7, §9.1. -negodata 이식 대상 — 견적/세션 생성 시 이 함수로 칸 rate 를 얻어 -anchor_price = target_price * (1000 - rate) // 1000 를 정수 연산으로 계산·박제한다. +negodata 이식 대상 — 견적/세션 생성 시 이 함수로 칸 anchoring_value 를 얻어 +anchoring_price = target_price * (1000 - value) // 1000 를 정수 연산으로 계산·박제한다. 순서: Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 시작값 → Redis SET(best effort). -supplier_type ∉ {1,2,3} 인 경우 호출하지 말고 get_base_rate_permille(bracket) 을 직접 쓴다(§9.1). +supplier_type ∉ {1,2,3} 인 경우 호출하지 말고 get_base_anchoring_value(price_range_index) 를 직접 쓴다(§9.1). """ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from anchoring.base_table import get_base_rate_permille -from anchoring.models import RateAdjustment -from anchoring.redis_client import get_rate, set_rate +from anchoring.base_table import get_base_anchoring_value +from anchoring.models import Adjustment +from anchoring.redis_client import get_value, set_value -async def get_latest_adjusted_rate(db: AsyncSession, company_id, supplier_type: int, bracket_index: int) -> int | None: - """칸의 최신 조정 행 rate_after. 이력 없으면 None.""" +async def get_latest_adjusted_value(db: AsyncSession, company_id, supplier_type: int, price_range_index: int) -> int | None: + """칸의 최신 조정 행 value_after. 이력 없으면 None.""" stmt = ( - select(RateAdjustment.anchor_rate_after) + select(Adjustment.anchoring_value_after) .where( - RateAdjustment.company_id == company_id, - RateAdjustment.supplier_type == supplier_type, - RateAdjustment.price_bracket_index == bracket_index, + Adjustment.company_id == company_id, + Adjustment.supplier_type == supplier_type, + Adjustment.price_range_index == price_range_index, ) - .order_by(RateAdjustment.id.desc()) + .order_by(Adjustment.adjustment_id.desc()) .limit(1) ) return (await db.execute(stmt)).scalar_one_or_none() -async def get_anchor_rate(db: AsyncSession, company_id, supplier_type: int, bracket_index: int) -> int: +async def get_current_anchoring_value(db: AsyncSession, company_id, supplier_type: int, price_range_index: int) -> int: """칸의 현재 앵커링 값(‰). Redis → 조정 이력 → 정적 테이블 → SET.""" - cached = await get_rate(company_id, supplier_type, bracket_index) + cached = await get_value(company_id, supplier_type, price_range_index) if cached is not None: return cached - rate = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket_index) - if rate is None: - rate = get_base_rate_permille(bracket_index) - await set_rate(company_id, supplier_type, bracket_index, rate, nx=True) - return rate + value = await get_latest_adjusted_value(db, company_id, supplier_type, price_range_index) + if value is None: + value = get_base_anchoring_value(price_range_index) + await set_value(company_id, supplier_type, price_range_index, value, nx=True) + return value diff --git a/schedules/anchoring/src/anchoring/redis_client.py b/schedules/anchoring/src/anchoring/redis_client.py index fa33b04..e1aac82 100644 --- a/schedules/anchoring/src/anchoring/redis_client.py +++ b/schedules/anchoring/src/anchoring/redis_client.py @@ -1,13 +1,13 @@ """Redis 캐시 클라이언트. 규범: §7. -- 키: anchor:{company_id}:{supplier_type}:{bracket_index} (supplier_type 은 SMALLINT 코드값) +- 키: anchor:{company_id}:{supplier_type}:{price_range_index} (supplier_type 은 SMALLINT 코드값) - 값: 정수 천분율 문자열, TTL 7일(주 방어선은 배치의 주간 re-SET) - 장애 내성 MUST: 에러 시 GET→None(DB 폴백), SET→로그만. Redis 가 견적/배치를 막으면 안 된다. """ import redis.asyncio as aioredis from anchoring.config import RedisConfig -from anchoring.constants import ANCHOR_RATE_MAX, ANCHOR_RATE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT +from anchoring.constants import ANCHORING_VALUE_MAX, ANCHORING_VALUE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT from anchoring.log import LOG _client: aioredis.Redis | None = None @@ -52,8 +52,8 @@ async def close_redis() -> None: _client = None -def anchor_key(company_id, supplier_type: int, bracket_index: int) -> str: - return f"anchor:{company_id}:{supplier_type}:{bracket_index}" +def anchor_key(company_id, supplier_type: int, price_range_index: int) -> str: + return f"anchor:{company_id}:{supplier_type}:{price_range_index}" async def ping() -> bool: @@ -66,26 +66,26 @@ async def ping() -> bool: return False -async def get_rate(company_id, supplier_type: int, bracket_index: int) -> int | None: +async def get_value(company_id, supplier_type: int, price_range_index: int) -> int | None: """캐시 조회. 미스·에러·클라이언트 미초기화 → None(호출측이 DB 폴백).""" if _client is None: return None try: - raw = await _client.get(anchor_key(company_id, supplier_type, bracket_index)) + raw = await _client.get(anchor_key(company_id, supplier_type, price_range_index)) if raw is None: return None - rate = int(raw) + value = int(raw) # 방어: 캐시 오염(외부 SET 등)으로 정책 범위 밖 값이 오면 미스로 취급 → DB 폴백 + 재적재로 자가 교정 - if not (ANCHOR_RATE_MIN <= rate <= ANCHOR_RATE_MAX): - LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, bracket_index)} value={raw}") + if not (ANCHORING_VALUE_MIN <= value <= ANCHORING_VALUE_MAX): + LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, price_range_index)} value={raw}") return None - return rate + return value except Exception as ex: - _note_failure("get", anchor_key(company_id, supplier_type, bracket_index), ex) + _note_failure("get", anchor_key(company_id, supplier_type, price_range_index), ex) return None -async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int, nx: bool = False) -> bool: +async def set_value(company_id, supplier_type: int, price_range_index: int, value: int, nx: bool = False) -> bool: """캐시 적재(best effort, TTL 7일). 실패해도 예외를 밖으로 던지지 않는다. nx=True: 키가 없을 때만 적재 — 읽기 경로의 미스 백필용(배치가 방금 쓴 새 값을 @@ -94,8 +94,8 @@ async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int if _client is None: return False try: - await _client.set(anchor_key(company_id, supplier_type, bracket_index), str(rate), ex=CACHE_TTL_SECONDS, nx=nx) + await _client.set(anchor_key(company_id, supplier_type, price_range_index), str(value), ex=CACHE_TTL_SECONDS, nx=nx) return True except Exception as ex: - _note_failure("set", anchor_key(company_id, supplier_type, bracket_index), ex) + _note_failure("set", anchor_key(company_id, supplier_type, price_range_index), ex) return False diff --git a/schedules/anchoring/src/anchoring/service.py b/schedules/anchoring/src/anchoring/service.py index b54b5f7..2732bb1 100644 --- a/schedules/anchoring/src/anchoring/service.py +++ b/schedules/anchoring/src/anchoring/service.py @@ -4,56 +4,58 @@ """ from bisect import bisect_right -from anchoring.base_table import get_base_rate_permille +from anchoring.base_table import get_base_anchoring_value from anchoring.constants import ( - ANCHOR_RATE_MAX, - ANCHOR_RATE_MIN, - BRACKET_INDEX_MAX, - DELTA_PERMILLE, + ANCHORING_VALUE_MAX, + ANCHORING_VALUE_MIN, + PRICE_RANGE_INDEX_MAX, + ADJUSTMENT_STEP, SAMPLE_THRESHOLD, UPPER_BOUNDS, AnchoringSampleType, ) -def calc_bracket_index(target_price: int) -> int: +def calc_price_range_index(target_price: int) -> int: """목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리. 좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸. 1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1""" - return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX) + return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX) -def calc_anchor_price(target_price: int, rate_permille: int) -> int: +def calc_anchoring_price(target_price: int, anchoring_value: int) -> int: """앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)""" - return target_price * (1000 - rate_permille) // 1000 + return target_price * (1000 - anchoring_value) // 1000 def judge_sample_type( is_done: bool, # sessions.status == DONE(3) bid_price: int | None, # 확정 투찰가(DONE 시) - last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음 - anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커) + last_offer_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음 + anchoring_price: int | None, # sessions.anchoring_price (박제 앵커) ) -> int: """배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적. 가격을 한 번이라도 써낸 협상만 표본: 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패. 가격 흔적이 없으면 제외. """ - if anchor_price is None or last_offered_price is None: + if anchoring_price is None or last_offer_price is None: return AnchoringSampleType.EXCLUDED.value - if is_done and bid_price is not None and bid_price <= anchor_price: + if is_done and bid_price is not None and bid_price <= anchoring_price: return AnchoringSampleType.BID_SUCCESS.value return AnchoringSampleType.BID_FAIL.value -def evaluate_pending( - rate_before: int, +def evaluate_samples( + value_before: int, sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드 supplier_type: int, # SMALLINT 코드 1/2/3 -) -> int | None: +) -> tuple[int, bool] | None: """누적 전량 평가. §4.4 - 반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월) + 반환: (anchoring_value_after, clamped) (평가 수행 시) / None (n < 10, 스킵·이월) + clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 — 경계값에서의 '유지'와 구분되는 + 실제 포화 신호(운영 지표용). 호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다. """ n = len(sample_types) @@ -61,21 +63,22 @@ def evaluate_pending( return None success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value) - delta = DELTA_PERMILLE[supplier_type] + delta = ADJUSTMENT_STEP[supplier_type] # r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피) if success * 10 >= n * 6: - adjusted = rate_before + delta + adjusted = value_before + delta elif success * 10 < n * 3: # r < 0.30 - adjusted = rate_before - delta + adjusted = value_before - delta else: # 0.30 ≤ r < 0.60 - adjusted = rate_before + adjusted = value_before - return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted)) + value_after = max(ANCHORING_VALUE_MIN, min(ANCHORING_VALUE_MAX, adjusted)) + return value_after, value_after != adjusted -def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int: +def get_current_value(latest_adjusted_value: int | None, price_range_index: int) -> int: """현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값.""" - if latest_adjusted_rate is not None: - return latest_adjusted_rate - return get_base_rate_permille(bracket_index) + if latest_adjusted_value is not None: + return latest_adjusted_value + return get_base_anchoring_value(price_range_index) diff --git a/schedules/anchoring/tests/conftest.py b/schedules/anchoring/tests/conftest.py index e2d62c2..69f56ef 100644 --- a/schedules/anchoring/tests/conftest.py +++ b/schedules/anchoring/tests/conftest.py @@ -80,15 +80,15 @@ class Seeder: async def seed_session( self, db, *, - supplier_type=1, target_price=30_000, anchor_price=29_700, rate=10, - status=3, bid_price=None, last_offered_price=..., qt_type=1, + supplier_type=1, target_price=30_000, anchoring_price=29_700, anchoring_value=10, + status=3, bid_price=None, last_offer_price=..., qt_type=1, ): """종료 재협상 세션 1건 시드. 반환: session_id. - last_offered_price 기본값은 bid_price(가격 흔적 = 투찰가). None 을 명시하면 가격 흔적 없는 세션. + last_offer_price 기본값은 bid_price(가격 흔적 = 투찰가). None 을 명시하면 가격 흔적 없는 세션. """ - if last_offered_price is ...: - last_offered_price = bid_price + if last_offer_price is ...: + last_offer_price = bid_price await self._ensure_item(db) qt_id = uuid.uuid4() self.quotation_ids.append(qt_id) @@ -105,19 +105,19 @@ class Seeder: await db.execute(text( "INSERT INTO negotiation.sessions " "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " - " target_price, target_anchoring_price, anchor_rate_permille, last_offered_price, " + " target_price, anchoring_price, anchoring_value, last_offer_price, " " status, bid_price, end_time) " - "VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :rate, :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(), - "qtype": qt_type, "tp": target_price, "ap": anchor_price, "rate": rate, - "lop": last_offered_price, "status": status, "bid": bid_price, + "qtype": qt_type, "tp": target_price, "ap": anchoring_price, "value": anchoring_value, + "lop": last_offer_price, "status": status, "bid": bid_price, }) return session_id async def cleanup(self, db): await db.execute(text( - "DELETE FROM anchoring.rate_adjustments WHERE company_id = :cid" + "DELETE FROM anchoring.adjustments WHERE company_id = :cid" ), {"cid": self.company_id}) if self.quotation_ids: await db.execute( diff --git a/schedules/anchoring/tests/test_batch.py b/schedules/anchoring/tests/test_batch.py index 74d17d0..245461d 100644 --- a/schedules/anchoring/tests/test_batch.py +++ b/schedules/anchoring/tests/test_batch.py @@ -9,8 +9,8 @@ from sqlalchemy import select, text from anchoring import db as adb from anchoring.batch import MarkingConflictError, _evaluate_cell, run_evaluation_batch -from anchoring.models import RateAdjustment, Session -from anchoring.reader import get_anchor_rate +from anchoring.models import Adjustment, Session +from anchoring.reader import get_current_anchoring_value from conftest import requires_db pytestmark = requires_db @@ -20,7 +20,7 @@ async def _run_batch(*seeders): """테스트 전용: 시드한 회사로 스코프 — 공유 dev DB 의 실데이터를 소비하지 않는다.""" return await run_evaluation_batch(force=True, company_ids=[s.company_id for s in seeders]) -# 시드 기본값: target 30,000 / rate 10‰ / anchor 29,700 → bracket 12 ("3만 원대" 칸) +# 시드 기본값: target 30,000 / anchoring_value 10‰ / anchoring_price 29,700 → price_range 12 ("3만 원대" 칸) BRACKET = 12 SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS FAIL_BID = 29_999 # > anchor → BID_FAIL @@ -28,15 +28,15 @@ FAIL_BID = 29_999 # > anchor → BID_FAIL async def _adjustments(db, seeder): stmt = ( - select(RateAdjustment) - .where(RateAdjustment.company_id == seeder.company_id) - .order_by(RateAdjustment.id) + select(Adjustment) + .where(Adjustment.company_id == seeder.company_id) + .order_by(Adjustment.adjustment_id) ) return (await db.execute(stmt)).scalars().all() async def _marks(db, session_ids): - stmt = select(Session.session_id, Session.anchoring_adjustment_id).where(Session.session_id.in_(session_ids)) + stmt = select(Session.session_id, Session.used_by_adjustment_id).where(Session.session_id.in_(session_ids)) return dict((await db.execute(stmt)).all()) @@ -54,9 +54,9 @@ async def test_full_cycle_and_idempotency(seeder, caplog): async with adb.session_scope() as db: ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등) for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패 - ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offered_price=FAIL_BID)) + ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offer_price=FAIL_BID)) # 가격 쓰고 이탈 → 견적 마감 시 일괄 NOT_PARTICIPATED = 실패 (중간 이탈 시나리오) - ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offered_price=FAIL_BID)) + ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offer_price=FAIL_BID)) # 합계 13건, 성공 8 → r≈0.615 → +20 with caplog.at_level(logging.INFO, logger="anchoring"): @@ -72,26 +72,26 @@ async def test_full_cycle_and_idempotency(seeder, caplog): adjustments = await _adjustments(db, seeder) assert len(adjustments) == 1 adj = adjustments[0] - assert (adj.nego_count, adj.success_count) == (13, 8) - assert (adj.anchor_rate_before, adj.anchor_rate_after) == (10, 30) - assert sorted(adj.consumed_session_ids) == sorted(str(i) for i in ids) + assert (adj.sample_count, adj.success_count) == (13, 8) + assert (adj.anchoring_value_before, adj.anchoring_value_after) == (10, 30) + assert sorted(adj.used_session_ids) == sorted(str(i) for i in ids) marks = await _marks(db, ids) - assert all(v == adj.id for v in marks.values()) # 13건 모두 소비 마킹 + assert all(v == adj.adjustment_id for v in marks.values()) # 13건 모두 소비 마킹 # 재실행 — 마킹 멱등: 우리 칸 조정은 그대로 1건 await _run_batch(seeder) async with adb.session_scope() as db: assert len(await _adjustments(db, seeder)) == 1 - # 조회용 뷰 — rate_history(이전→새 값 리스트업) / current_rates(칸별 현재값) + # 조회용 뷰 — value_history(이전→새 값 리스트업) / current_values(칸별 현재값) hist = (await db.execute(text( - "SELECT anchor_rate_before, anchor_rate_after, delta_permille, success_rate " - "FROM anchoring.rate_history WHERE company_id = :c"), {"c": seeder.company_id})).one() - assert (hist.anchor_rate_before, hist.anchor_rate_after, hist.delta_permille) == (10, 30, 20) + "SELECT anchoring_value_before, anchoring_value_after, value_change, success_rate " + "FROM anchoring.value_history WHERE company_id = :c"), {"c": seeder.company_id})).one() + assert (hist.anchoring_value_before, hist.anchoring_value_after, hist.value_change) == (10, 30, 20) assert float(hist.success_rate) == 0.615 cur = (await db.execute(text( - "SELECT anchor_rate_permille FROM anchoring.current_rates " - "WHERE company_id = :c AND supplier_type = 1 AND price_bracket_index = :b"), + "SELECT anchoring_value FROM anchoring.current_values " + "WHERE company_id = :c AND supplier_type = 1 AND price_range_index = :b"), {"c": seeder.company_id, "b": BRACKET})).scalar_one() assert cur == 30 @@ -113,10 +113,10 @@ async def test_carryover(seeder): async with adb.session_scope() as db: adjustments = await _adjustments(db, seeder) assert len(adjustments) == 1 - assert adjustments[0].nego_count == 13 # 4주치 전량 1회 평가 - assert adjustments[0].anchor_rate_after == 30 + assert adjustments[0].sample_count == 13 # 4주치 전량 1회 평가 + assert adjustments[0].anchoring_value_after == 30 marks = await _marks(db, first + second) - assert all(v == adjustments[0].id for v in marks.values()) + assert all(v == adjustments[0].adjustment_id for v in marks.values()) # ── §11.5: 회사 격리 + 현재값 조회(무Redis DB 폴백) + δ 유형 차원 ── @@ -130,19 +130,19 @@ async def test_company_isolation_and_reader(seeder): other_company = uuid.uuid4() async with adb.session_scope() as db: adjustments = await _adjustments(db, seeder) - by_type = {a.supplier_type: a.anchor_rate_after for a in adjustments} + by_type = {a.supplier_type: a.anchoring_value_after for a in adjustments} assert by_type == {1: 30, 2: 20} - # 조정된 칸은 새 rate, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값 - assert await get_anchor_rate(db, seeder.company_id, 1, BRACKET) == 30 - assert await get_anchor_rate(db, other_company, 1, BRACKET) == 10 + # 조정된 칸은 새 anchoring_value, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값 + assert await get_current_anchoring_value(db, seeder.company_id, 1, BRACKET) == 30 + assert await get_current_anchoring_value(db, other_company, 1, BRACKET) == 10 # ── §11.5: EXCLUDED 마킹 0 + 유효 n<10 이월 + supplier_type 미지정 ── async def test_excluded_and_unsampleable(seeder): async with adb.session_scope() as db: # 가격 흔적 없는 종료(무가격 결렬·미참여) → EXCLUDED - excluded = [await seeder.seed_session(db, status=5, last_offered_price=None) for _ in range(8)] - excluded += [await seeder.seed_session(db, status=4, last_offered_price=None) for _ in range(7)] + excluded = [await seeder.seed_session(db, status=5, last_offer_price=None) for _ in range(8)] + excluded += [await seeder.seed_session(db, status=4, last_offer_price=None) for _ in range(7)] valid = await _seed_mixed(db, seeder, success=5, fail=0) # 유효 5 < 10 untyped = [await seeder.seed_session(db, supplier_type=0, bid_price=SUCCESS_BID)] # 칸 구성 불가 untyped.append(await seeder.seed_session(db, supplier_type=None, bid_price=SUCCESS_BID)) # NULL 도 동일(§13-6) @@ -163,7 +163,7 @@ async def test_marking_conflict_rolls_back(seeder): ids = await _seed_mixed(db, seeder, success=10, fail=0) # 경합 시뮬레이션: 1건을 다른 실행이 먼저 소비한 상태로 만든다 await db.execute(text( - "UPDATE negotiation.sessions SET anchoring_adjustment_id = 999999 WHERE session_id = :sid" + "UPDATE negotiation.sessions SET used_by_adjustment_id = 999999 WHERE session_id = :sid" ), {"sid": ids[0]}) samples = [(sid, 1) for sid in ids] # 10건 전부 BID_SUCCESS 로 평가 시도 @@ -184,7 +184,7 @@ async def test_marking_conflict_rolls_back(seeder): async def test_priced_rate_zero_warns(seeder, caplog): async with adb.session_scope() as db: for _ in range(3): # 전부 가격 흔적 없는 종료 → priced_rate 0 - await seeder.seed_session(db, status=5, last_offered_price=None) + await seeder.seed_session(db, status=5, last_offer_price=None) with caplog.at_level(logging.WARNING, logger="anchoring"): await run_evaluation_batch(force=True, company_ids=[seeder.company_id]) @@ -195,7 +195,7 @@ async def test_priced_rate_zero_warns(seeder, caplog): async def test_dry_run_changes_nothing(seeder, caplog): async with adb.session_scope() as db: ids = await _seed_mixed(db, seeder, success=10, fail=0) - excluded = [await seeder.seed_session(db, status=5, last_offered_price=None)] + excluded = [await seeder.seed_session(db, status=5, last_offer_price=None)] with caplog.at_level(logging.INFO, logger="anchoring"): result = await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True) @@ -216,8 +216,8 @@ async def test_dry_run_changes_nothing(seeder, caplog): # ── 박제 정합 감시: 정수식과 박제 anchor 불일치 → WARN ──── async def test_snapshot_mismatch_warns(seeder, caplog): async with adb.session_scope() as db: - # rate 10‰ 기준 정수식 anchor 는 29,700 — 29,000 으로 박제된 세션은 이식 오류 신호 - await seeder.seed_session(db, rate=10, anchor_price=29_000, bid_price=28_000) + # anchoring_value 10‰ 기준 정수식 앵커가는 29,700 — 29,000 으로 박제된 세션은 이식 오류 신호 + await seeder.seed_session(db, anchoring_value=10, anchoring_price=29_000, bid_price=28_000) with caplog.at_level(logging.WARNING, logger="anchoring"): await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True) diff --git a/schedules/anchoring/tests/test_core.py b/schedules/anchoring/tests/test_core.py index cc03cea..e3cd149 100644 --- a/schedules/anchoring/tests/test_core.py +++ b/schedules/anchoring/tests/test_core.py @@ -6,14 +6,14 @@ from datetime import datetime import pytest -from anchoring.base_table import BaseTableError, _validate, get_base_rate_permille, load_base_table -from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS, AnchoringSampleType +from anchoring.base_table import BaseTableError, _validate, get_base_anchoring_value, load_base_table +from anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS, AnchoringSampleType from anchoring.batch import is_evaluation_week from anchoring.service import ( - calc_anchor_price, - calc_bracket_index, - evaluate_pending, - get_current_rate, + calc_anchoring_price, + calc_price_range_index, + evaluate_samples, + get_current_value, judge_sample_type, ) @@ -23,32 +23,32 @@ E = AnchoringSampleType.EXCLUDED.value # ── §11.1 앵커링가 계산 (내림 검증) ────────────────────── -def test_calc_anchor_price_floor(): - assert calc_anchor_price(30_000, 200) == 24_000 - assert calc_anchor_price(26_706, 10) == 26_438 # 26,438.94 → 내림 - assert calc_anchor_price(29_999, 15) == 29_549 # 29,549.015 → 내림 - assert calc_anchor_price(0, 10) == 0 +def test_calc_anchoring_price_floor(): + assert calc_anchoring_price(30_000, 200) == 24_000 + assert calc_anchoring_price(26_706, 10) == 26_438 # 26,438.94 → 내림 + assert calc_anchoring_price(29_999, 15) == 29_549 # 29,549.015 → 내림 + assert calc_anchoring_price(0, 10) == 0 # ── §11.2 구간 인덱스 (자릿수 계단식 사다리 · 상한 클램프) ── -def test_bracket_index(): - assert calc_bracket_index(0) == 0 # [0, 1,000) 통일 칸 - assert calc_bracket_index(999) == 0 - assert calc_bracket_index(1_000) == 1 # 경계는 상위 구간 - assert calc_bracket_index(1_999) == 1 # 1천 원대 - assert calc_bracket_index(9_999) == 9 # 9천 원대 - assert calc_bracket_index(10_000) == 10 # 1만 원대 진입 - assert calc_bracket_index(30_000) == 12 # 3만 원대 - assert calc_bracket_index(99_999) == 18 # 9만 원대 - assert calc_bracket_index(150_000) == 19 # 10만 원대 - assert calc_bracket_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입 - assert calc_bracket_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸 - assert calc_bracket_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프 +def test_price_range_index(): + assert calc_price_range_index(0) == 0 # [0, 1,000) 통일 칸 + assert calc_price_range_index(999) == 0 + assert calc_price_range_index(1_000) == 1 # 경계는 상위 구간 + assert calc_price_range_index(1_999) == 1 # 1천 원대 + assert calc_price_range_index(9_999) == 9 # 9천 원대 + assert calc_price_range_index(10_000) == 10 # 1만 원대 진입 + assert calc_price_range_index(30_000) == 12 # 3만 원대 + assert calc_price_range_index(99_999) == 18 # 9만 원대 + assert calc_price_range_index(150_000) == 19 # 10만 원대 + assert calc_price_range_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입 + assert calc_price_range_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸 + assert calc_price_range_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프 def test_ladder_shape(): """사다리 자체 검증: 1 + 자릿수(5)×9 = 46칸, 단조 증가, 마지막 1억.""" - assert BRACKET_COUNT == 46 + assert PRICE_RANGE_COUNT == 46 assert UPPER_BOUNDS[0] == 1_000 and UPPER_BOUNDS[-1] == 100_000_000 assert list(UPPER_BOUNDS) == sorted(set(UPPER_BOUNDS)) assert UPPER_BOUNDS[9] == 10_000 and UPPER_BOUNDS[18] == 100_000 # 자릿수 경계 @@ -57,8 +57,8 @@ def test_ladder_shape(): # ── §2 정적 테이블 로드·검증 ───────────────────────────── def test_base_table_load_and_values(): load_base_table() - assert get_base_rate_permille(0) == 10 - assert get_base_rate_permille(45) == 10 + assert get_base_anchoring_value(0) == 10 + assert get_base_anchoring_value(45) == 10 def _rows(): @@ -69,8 +69,8 @@ def _rows(): def test_base_table_validate_ok(): - rates = _validate(_rows()) - assert len(rates) == BRACKET_COUNT and set(rates) == {10} + values = _validate(_rows()) + assert len(values) == PRICE_RANGE_COUNT and set(values) == {10} def test_base_table_validate_rejects_bad(): @@ -95,32 +95,35 @@ def _pending(success: int, fail: int) -> list[int]: return [S] * success + [F] * fail -def test_evaluate_pending_distribution(): - assert evaluate_pending(10, _pending(8, 5), 1) == 30 # 13건 r≈0.615 → +20 - assert evaluate_pending(10, _pending(7, 6), 1) == 10 # r≈0.538 → 유지 - assert evaluate_pending(10, _pending(3, 10), 1) == 10 # r≈0.231 → −20, 하한 clamp - assert evaluate_pending(10, _pending(6, 4), 1) == 30 # r=0.60 정확히 → 경계 포함 +20 - assert evaluate_pending(10, _pending(3, 7), 1) == 10 # r=0.30 정확히 → 유지 - assert evaluate_pending(10, _pending(9, 0), 1) is None # n=9 → 평가 안 함(이월) +def test_evaluate_samples_distribution(): + assert evaluate_samples(10, _pending(8, 5), 1) == (30, False) # 13건 r≈0.615 → +20 + assert evaluate_samples(10, _pending(7, 6), 1) == (10, False) # r≈0.538 → 유지 + assert evaluate_samples(10, _pending(3, 10), 1) == (10, True) # r≈0.231 → −20, 하한 clamp + assert evaluate_samples(10, _pending(6, 4), 1) == (30, False) # r=0.60 정확히 → 경계 포함 +20 + assert evaluate_samples(10, _pending(3, 7), 1) == (10, False) # r=0.30 정확히 → 유지 + assert evaluate_samples(10, _pending(9, 0), 1) is None # n=9 → 평가 안 함(이월) -def test_evaluate_pending_delta_swap_guard(): +def test_evaluate_samples_delta_swap_guard(): """δ 스왑 가드 (MUST): 코드 2=제조=±10, 3=총판=±15.""" all_success = [S] * 10 - assert evaluate_pending(10, all_success, 2) == 20 # 제조 +10 - assert evaluate_pending(10, all_success, 3) == 25 # 총판 +15 + assert evaluate_samples(10, all_success, 2) == (20, False) # 제조 +10 + assert evaluate_samples(10, all_success, 3) == (25, False) # 총판 +15 all_fail = [F] * 10 - assert evaluate_pending(100, all_fail, 2) == 90 # 제조 −10 - assert evaluate_pending(100, all_fail, 3) == 85 # 총판 −15 + assert evaluate_samples(100, all_fail, 2) == (90, False) # 제조 −10 + assert evaluate_samples(100, all_fail, 3) == (85, False) # 총판 −15 -def test_evaluate_pending_clamp_upper(): - assert evaluate_pending(200, _pending(9, 1), 1) == 200 # 상한 clamp — 조정 레코드는 호출측이 INSERT +def test_evaluate_samples_clamp_upper(): + # 상한 clamp — 조정 레코드는 호출측이 INSERT + assert evaluate_samples(200, _pending(9, 1), 1) == (200, True) + # 경계값에서의 '유지'(hold 밴드)는 clamp 가 아니다 — clamped 지표 오염 방지 + assert evaluate_samples(200, _pending(5, 5), 1) == (200, False) # ── §11.4 파생 판정 ("가격 흔적" 기준) ──────────────────── def test_judge_sample_type(): - # judge_sample_type(is_done, bid_price, last_offered_price, anchor_price) + # judge_sample_type(is_done, bid_price, last_offer_price, anchoring_price) assert judge_sample_type(True, 24_000, 24_000, 24_000) == S # 같아도 성공 assert judge_sample_type(True, 24_001, 24_001, 24_000) == F # 앵커 초과 합의(와일드카드 등) assert judge_sample_type(False, None, 25_000, 24_000) == F # 가격 쓰고 결렬(REJECTED) @@ -130,9 +133,9 @@ def test_judge_sample_type(): # ── §4.5 현재 값 조회 ──────────────────────────────────── -def test_get_current_rate(): - assert get_current_rate(70, 0) == 70 - assert get_current_rate(None, 0) == 10 # 이력 없으면 정적 테이블 시작값 +def test_get_current_value(): + assert get_current_value(70, 0) == 70 + assert get_current_value(None, 0) == 10 # 이력 없으면 정적 테이블 시작값 # ── §8 격주 게이트 (ISO 주차 짝수 토요일만 평가) ──────────