장기 운영 관점 자체 점검에서 나온 6개 구멍 반영:
- 회차 추적: 모든 배치 라인에 [batch {run_id}] 태그 + 시작 로그(ISO 주차·force)
+ 상주 기동 시 다음 실행 예정 시각 출력
- 회사별 구분: 칸별 조정 상세(company= type= bracket= n= 성공= before‰→after‰
adj_id=)와 회사요약(테넌트당 1줄: 평가/상승/유지/하락/이월/실패/제외) —
grep company=<uuid> 로 테넌트 단위 추적, adj_id 로 DB 행 교차 확인
- 경보 연결: failed_cells>0 이면 종료 요약 WARNING 승격, Redis 실패는 연산별
처음 5건만 WARN 후 누계 요약(폭주 억제)
- 시간대: 로그 타임스탬프를 컨테이너 TZ 무관 KST(+0900) 고정, slim 컨테이너
zoneinfo 보장용 tzdata 의존 추가
- 스케줄 가시성: apscheduler 로거를 동일 핸들러에 연결(misfire 등 유실 방지)
- 로테이션: compose 에 json-file 10MB×5 설정(디스크 보호)
docs/개발용.md §8 로그 규약, README 로그 확인법 추가. 테스트에 로그 규약
검증(run_id 태그·조정 라인·회사요약) 포함 — 15 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
44 KiB
앵커링 시스템 구현 스펙 (개발용)
문서 성격: 이 문서만 보고 앵커링 시스템을 구현·유지보수할 수 있도록 작성된 규범 문서(최종 확정본). 규범 언어:
MUST= 반드시 준수,MUST NOT= 금지,SHOULD= 권장,MAY= 선택. 스택: FastAPI(async) + PostgreSQL(SQLAlchemy async / SQL 수동 적용, Alembic 없음) + Redis + APScheduler —schedules/anchoring자립 컨테이너(backend 내장 아님). 버전: v1.2 (2026-07-02 확정) — 정책 배경은기획용.md, 흐름 해설은워크플로우.md, 타 팀(negodata) 적용 명세는인수인계.md참조.
목차
- 확정 결정 요약
- 정적 기본 테이블
- 상수 정의
- 도메인 규칙
- 아키텍처
- DB 스키마
- Redis 캐시 규약
- 배치 잡 명세
- 견적 생성·협상 플로우
- 참조 구현
- 검증 벡터 (Golden Tests)
- 금지·봉인 사항
- 선행·연계 작업
1. 확정 결정 요약
| 항목 | 결정 |
|---|---|
| 역할 분담 | schedules/anchoring 자립 모듈 = 정적 테이블·rate 조회(reader)·조정 배치·Redis 규약·DDL 소유, 독립 컨테이너로 자체 스케줄 실행 / negodata = 세션 생성 시 reader 로 rate 조회 → 앵커가·rate 박제 (인수인계, §9.1) / backend = 협상 채팅(박제값 소비 + 마지막 제시가 기록, anchoring 모듈 무의존) (§9.2) / agent = 변경 없음(앵커 비노출 — 정보 비대칭 전략) |
| 소유·수정 범위 | 직접 수정 가능 = backend·frontend·schedules(우리 모듈). negodata·agent는 인수인계 문서로 전달 → 담당 개발자가 적용 |
| 기본 테이블 | 서비스 시작 시 메모리 로드되는 불변 정적 테이블 (src/anchoring/resources/anchoring_base.json, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스 |
| 가격 상한 | 정적 테이블 상한 = 정확히 1억 원. target_price > 1억은 전부 마지막 인덱스(idx 33334) 로 클램프 |
| 멀티테넌시 | 앵커링 값은 회사(company)별로 독립 — 칸 키에 company_id(uuid) 포함 |
| 표본 | 전용 테이블 없음. 종료된 재협상 세션(negotiation.sessions)의 종료 후 불변 컬럼(target_anchoring_price, anchor_rate_permille, last_offered_price, bid_price, status)에서 배치 시점에 파생 판정한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 |
| 표본 기준 | "가격 흔적": 협력사가 가격을 한 번이라도 써낸(last_offered_price 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 |
| 앵커 비노출 | agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작 |
| 조정 이력 저장 | append-only 조정 이력 anchoring.rate_adjustments 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 |
| 소비 경계 | sessions.anchoring_adjustment_id 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = 한 트랜잭션 |
| 평가 트리거 | 격주 토요일 00:00 (KST), 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가 |
| 평가 방식 | 누적 전량 평가: 미처리 유효 표본 전부(n건)로 r = 성공/n 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 → 다음 주기 자연 이월 |
| 앵커링가 반올림 | 1원 단위 내림(floor) — 정수 연산만 사용 |
| 값 표현 | 앵커링 값은 정수 천분율(‰) 로 저장·계산 (부동소수점 산술 금지) |
| 코드값 | 프로젝트 컨벤션: SMALLINT 1-based 코드 + 앱 enum 매핑, DB CHECK/FK/ENUM 없음 |
2. 정적 기본 테이블
서비스 시작 시 메모리에 로드되는 불변 리스트. DB에 저장하지 않으며, 런타임에 절대 수정하지 않는다 (MUST NOT).
파일: src/anchoring/resources/anchoring_base.json (리포에 커밋, 33,334행). 키는 프로젝트 컨벤션대로 snake_case.
[
{ "idx": 1, "upper_bound": 3000, "anchoring_value": 0.01 },
{ "idx": 2, "upper_bound": 6000, "anchoring_value": 0.01 },
...
{ "idx": 33333, "upper_bound": 99999000, "anchoring_value": 0.01 },
{ "idx": 33334, "upper_bound": 100000000, "anchoring_value": 0.01 }
]
마지막 행(idx 33334)만
upper_bound가idx × 3000(= 100,002,000)이 아니라 정확히 100,000,000(1억) 이다. 1억 초과 가격은 전부 이 마지막 인덱스로 클램프된다(§2.1).
2.1 매핑 규약 (MUST)
| 항목 | 규약 |
|---|---|
| 구간 범위 | idx k의 구간 = [upper_bound − 3000, upper_bound) 좌폐우개 |
| 경계값 소속 | target_price가 정확히 upper_bound와 같으면 다음 idx 소속. 예: 3,000원 → idx 2 |
| 내부 인덱스 변환 | bracket_index = idx − 1 = target_price // 3000 (0-기반). DB·Redis·코드 내부는 bracket_index 사용 |
| 상한 클램프 | target_price ≥ 99,999,000 → 전부 최상위 구간(idx 33334, bracket_index 33333). 1억 초과도 예외 없이 마지막 인덱스 |
| 시작값 | 칸의 시작 앵커링 값 = 해당 idx의 anchoring_value 천분율 변환 정수: int(anchoring_value * 1000). 현재 전 구간 10‰ |
| 기동 검증 | 로드 시 33,334행·idx 연속(1..33334)·upper_bound == min(idx*3000, 100_000_000)·0.01 ≤ anchoring_value ≤ 0.20 검증, 실패 시 기동 중단 (§13) |
- 시작값은 정적 테이블에서만 읽는다. 코드에
0.01/10하드코딩 MUST NOT (테이블이 유일한 소스). anchoring_value는 회사 무관 공통. 회사별 차이는 조정 이력의 누적에서만 발생한다.
3. 상수 정의
모든 비율은 정수 천분율(permille). 10‰ = 1%.
# src/anchoring/constants.py
ANCHOR_RATE_MIN = 10 # 하한 1%
ANCHOR_RATE_MAX = 200 # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(§2)에서 로드
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름)
DELTA_PERMILLE = {
1: 20, # 유통(DISTRIBUTION) ±2%
2: 10, # 제조(MANUFACTURE) ±1%
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
}
SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면 스킵·이월)
PRICE_BRACKET_UNIT = 3_000 # 가격구간 폭 (원)
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
BRACKET_INDEX_MAX = 33_333 # 0-기반 구간 인덱스 상한 (총 33,334칸)
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값
CACHE_TTL_SECONDS = 7 * 24 * 3600 # Redis 키 TTL(§7) — stale 잔존 방지 보조
REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
앱 enum — backend 는 anchoring 판정을 하지 않으므로(무의존) enum 은 모듈 내부(constants.py)에 둔다 (프로젝트 컨벤션 — plain Enum, 1-based, 대상 컬럼 docstring):
class SupplierType(Enum):
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type
(negodata SupplierType 과 동일 코드)"""
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
DISTRIBUTION = 1 # 유통
MANUFACTURE = 2 # 제조
SOLE_AGENCY = 3 # 총판
class AnchoringSampleType(Enum):
"""앵커링 표본 판정 결과(파생값 — DB 에 저장하지 않음, 평가 로직·로그용).
기준 = "가격 흔적": 가격을 써낸 협상만 표본."""
BID_SUCCESS = 1 # 정상종료 + bid ≤ 박제 앵커
BID_FAIL = 2 # 가격 흔적 있으나 성공 아님 (앵커 초과 합의 / 결렬 / 가격 쓰고 이탈·만료)
EXCLUDED = 3 # 가격 흔적 없음 / 앵커 박제 없음 / 유형 미지정
- 상수 변경은 정책 재확정 사안이다. 코드에서 임의 조정 MUST NOT.
- 앵커링 값을 float으로 저장·연산 MUST NOT. 모든 산술은 정수로 수행한다 (성공률 비교도 §10처럼 정수 비교).
4. 도메인 규칙
4.1 칸(cell) 식별
칸 = (company_id, supplier_type, bracket_index) 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.
bracket_index = min(target_price // 3_000, 33_333)
bracket_index산출 기준 가격은 목표가(target_price) 다 (MUST). 1억 이상(≥ 99,999,000)은 전부 마지막 인덱스 33333.- 칸 해석 소스:
company_id=partner.items.company_id(세션의 item 소유 회사 = 갑),supplier_type=quotation.quotations.supplier_type(재협상 1:1 견적에 기록됨). - 같은 구간·유형이라도 회사가 다르면 서로 다른 칸. 회사 간 표본·값 공유 MUST NOT.
- 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
supplier_type ∉ {1,2,3}이거나company_id미해석 세션은 칸을 구성할 수 없다 → 가격 산출은 정적 테이블 시작값으로 동작(§9), 집계에서는 제외(§4.3).
4.2 앵커링가 계산
anchor_price = target_price × (1000 − anchor_rate_permille) // 1000
target_price가 정수(원)이므로 위 식은 정수 연산만으로 정확한 내림을 보장한다.- 부동소수점 곱셈 경유 MUST NOT (
int(price * 0.99),round(price * 0.99)형태 금지). - 결과는 항상 1원 단위 정수.
4.3 표본 판정 (배치 시점 파생 — "가격 흔적" 기준)
표본 = 종료된 재협상 세션 중 협력사가 가격을 한 번이라도 써낸 것. 전용 테이블 없이, 배치가 아래 종료 후 불변 입력에서 판정을 파생한다.
한 줄 요약: "가격을 써낸 협상만 세고 — 앵커 이하로 합의됐으면 성공, 나머지는 전부 실패."
판정 입력:
| 컬럼 | 의미 | 기록 시점 |
|---|---|---|
sessions.target_anchoring_price |
제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) |
sessions.anchor_rate_permille |
제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 |
sessions.last_offered_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_FAIL |
가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | O | X |
EXCLUDED |
last_offered_price IS NULL(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 |
X | — |
- "유효 표본" =
EXCLUDED가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다. - 왜 실패에 결렬·이탈이 반드시 포함돼야 하나: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(
check_price_match) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다. - 판정 입력 컬럼은 종료 후 절대 수정 금지 (MUST NOT — §12).
last_offered_price만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다.
4.4 평가 산식 (누적 전량 평가)
배치 시점에 칸별로 수행한다.
pending = 해당 칸의 미처리(anchoring_adjustment_id IS NULL) 유효 표본 전부
n = |pending|
n < 10 → 평가하지 않음. 마킹도 하지 않음 → 다음 주기로 이월 (자동으로 4주, 6주, …치가 됨)
n ≥ 10 → r = (pending 중 BID_SUCCESS 건수) / n
┌ +δ(p) if r ≥ 0.60
delta = ┤ 0 if 0.30 ≤ r < 0.60
└ −δ(p) if r < 0.30
anchor_rate_after = clamp(anchor_rate_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)
- 분모는 항상 실제 누적 건수 n (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다.
- delta = 0이어도, clamp에 막혀 값이 안 변해도 조정 레코드는 반드시 INSERT하고 표본을 소비(마킹)한다 (MUST).
- "표본 소비" = 마킹. 물리 삭제 없음.
EXCLUDED·칸 구성 불가 세션은 평가와 무관하게anchoring_adjustment_id = 0으로 일괄 마킹해 재스캔을 방지한다. - 한 칸은 한 배치에서 최대 1회 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장).
4.5 현재 앵커링 값 조회
값은 저장된 단일 상태가 아니라 조정 이력의 최신 행이다.
rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after
없으면 → 정적 테이블 시작값 (§2.1)
- 재현성: 조정 행에 박제된
consumed_session_ids(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. 조정 이력은 유일 진실 원천이며 보호 대상이다 (백업 정책 적용 MUST). - 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된
consumed_session_ids를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 MUST NOT (배치 시각 의존이므로 불가능). - 알려진 완화:
sessions행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다.
5. 아키텍처
[anchoring 서비스 기동 — schedules/anchoring 독립 컨테이너]
정적 기본 테이블 메모리 로드·검증 (불변, §2) ── Redis 클라이언트 init ── APScheduler 기동
[견적/세션 생성 — negodata, 인수인계 §9.1]
│ 칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000−rate)//1000 (정수)
│ → 세션 INSERT 에 target_anchoring_price + anchor_rate_permille 박제 (재생성 상속 폐지)
▼
[협상 채팅 — backend, §9.2 — anchoring 모듈 무의존]
│ 박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계)
│ 가격 입력 턴마다 last_offered_price 갱신 (가격 흔적)
▼
negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼)
│
│ 격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3)
│ → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션)
▼
anchoring.rate_adjustments ────────── 진실 원천 (INSERT only, consumed_session_ids·값 변화 박제)
│
│ 배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합)
▼
Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7일
│
│ GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블)
▼
[다음 견적/세션 생성] 조정된 rate 로 앵커가 산출
- 조정 이력 테이블에 UPDATE / DELETE MUST NOT.
- 배치가
sessions에 쓰는 것은anchoring_adjustment_id단 하나 — 다른 컬럼 수정 MUST NOT. - 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외).
- 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요.
- Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8).
6. DB 스키마
프로젝트 컨벤션 준수: FK/CHECK/PG ENUM 없음, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 모듈 소유 — schema.sql 한 파일(스키마+테이블+sessions ALTER+인덱스, psql 수동 적용). postgres-init 에는 anchoring 파일을 두지 않는다.
네이밍 결정 — 기존 코드베이스 용어와 통일:
| 개념 | 명칭 | 이유 |
|---|---|---|
| 조정 이력 테이블 | anchoring.rate_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 |
"이 조정이 소비한 세션"임을 명시 |
| 생성 시각 | created_at |
프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각) |
| 소비 마킹 | sessions.anchoring_adjustment_id |
조정 테이블명과 정합 |
6.1 조정 이력 (신설 — 유일한 새 테이블)
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..33333 (앱 보장)
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 INDEX IF NOT EXISTS idx_rate_adjustments_cell
ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC);
6.2 sessions 확장 (기존 테이블 ALTER)
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
-- 배치 스캔 최적화: 미처리 세션만
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
ON negotiation.sessions (qt_type, status)
WHERE anchoring_adjustment_id IS NULL AND deleted = false;
주의사항:
sessions.target_anchoring_price는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님).- 신규 DB 구축 시 적용 순서:
postgres-init/01~04→schedules/anchoring/schema.sql(IF NOT EXISTS 라 재적용 안전). - backend 모델(
models.py)에는 sessions 3컬럼만 추가한다 —rate_adjustments모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함).
7. Redis 캐시 규약
| 항목 | 규약 |
|---|---|
| 키 | anchor:{company_id}:{supplier_type}:{bracket_index} — supplier_type 은 SMALLINT 코드값. 예: anchor:0b0e…:1:10 |
| 값 | 정수 천분율 문자열. 예: "30" |
| TTL | 7일 (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) |
| 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → SET 후 사용 |
| 갱신 | 배치가 평가한 칸 SET + 매주 토 잡 실행 시(격주 게이트 무관) 조정 이력 보유 칸 전체 re-SET (§8 절차 0.5) |
| 장애 내성 | Redis 에러 시 GET→None 취급(DB 폴백), SET 은 로그만 남기고 무시 (MUST — 견적 생성·배치를 Redis 가 막으면 안 됨). socket timeout 0.2~0.5초 설정 MUST(행 방지) |
- 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
- ⚠️ stale 키는 "미스"가 나지 않는다: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다.
- 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요.
- 클라이언트:
redis.asyncio— 사용 주체는 anchoring 서비스(배치 SET/re-SET)와 negodata(reader GET, 인수인계). backend 는 Redis 를 쓰지 않는다. 설정은 모듈config.toml+REDIS_HOST/PORT/PASSWORDenv 오버라이드. Redis 인스턴스는 모듈 docker-compose 에 동봉(negodata 가 같은 인스턴스를 바라봄).
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회, 게이트 무시). - 스케줄: 매주 토 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 다. - 원자성: 조정 INSERT 와 세션 마킹은 같은 DB 세션의 한 트랜잭션에서 실행한다(MUST). 모듈은 자체 async 엔진(
session_scope)을 쓰므로 자연 충족된다. (참고: backend 의DB_SESSION_MNG.execute_lambda_run은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
절차 (run_evaluation_batch(force=False)):
0. force 아니고 격주 게이트 미충족 → 절차 0.5 만 수행 후 종료
0.5. 캐시 정합(매주, 게이트 무관): 조정 이력 보유 칸 전체의 최신 rate 를 Redis 일괄 re-SET
(SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7)
1. 미처리 종료 재협상 세션 스캔:
sessions s JOIN quotation.quotations q ON q.qt_id = s.quotation_id
JOIN partner.items i ON i.item_id = s.item_id
WHERE s.anchoring_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 일괄 마킹 (재스캔 방지)
- 유효 표본 → 칸별 그룹 적재
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
anchor_rate_before = 최신 조정 anchor_rate_after (없으면 정적 테이블 시작값)
anchor_rate_after = evaluate_pending(...) # §4.4 / §10
① anchoring.rate_adjustments INSERT (consumed_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
(backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
로그 규약 (운영 추적):
-
출력 = stdout(컨테이너 json-file 드라이버, compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 항상 KST(+0900).
-
모든 배치 라인에
[batch {run_id}]태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은company= type= bracket=key=value 형식 → 회사별 grep(grep company=<uuid>). -
라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → 칸별 조정 상세(
n= 성공= before‰→after‰ adj_id=— DB 행과 교차 확인) → 회사요약(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약. -
레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) /
failed_cells > 0이면 종료 요약을 WARNING 으로 승격(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN. -
상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).
-
INSERT+마킹(3)과 Redis SET(4) 사이 장애 시: 캐시는 stale이지만 TTL(7일)·다음 주 re-SET(절차 0.5)이 회복한다. 트랜잭션은 DB까지만 보장하면 된다.
-
n < 10 칸의 유효 표본은 마킹하지 않는다 — 그것이 이월이다.
-
배치 실패·지연 시에도 견적 생성·협상은 캐시(또는 on-demand 조회)로 계속 동작한다.
-
별도 batch_runs 테이블 없음 — 조정 이력이 곧 실행 기록이며, 회차 요약은 LOG로 남긴다.
-
운영 런북: 토 00:00 에 서비스가 내려가 있었다면(misfire_grace 1h 초과) 그 회차는 스킵되고 패리티 게이트 때문에 2주 뒤 실행된다. 누적 평가라 데이터 손실은 없으나, 재기동 후
--once수동 1회 실행으로 즉시 따라잡을 수 있다.
9. 견적 생성·협상 플로우 (역할 분담)
앵커가의 산출·박제 주체는 negodata(견적 생성 측) 이고, backend(협상 채팅)는 박제값의 소비자다. negodata 변경은 직접 수정하지 않고 인수인계.md로 전달한다. agent 는 변경하지 않는다.
9.1 견적/세션 생성 — negodata (인수인계 대상)
현재 negodata _build_quotation은 세션 생성 시 target_anchoring_price를 구 방식으로 채운다
(신규: int(tp * (1 - quotation_settings.anchoring_value)) float 계산 / 재생성: 직전 라운드 값 상속).
새 앵커링 모듈 전달 후 아래로 교체된다:
세션(상품 × 공급사) 생성 시마다:
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
bracket_index = min(target_price // 3000, 33333)
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 포함 (박제)
- 재생성 상속 폐지 (MUST): 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지.
quotation_settings.anchoring_value는 앵커가 계산에 더 이상 사용하지 않는다(컬럼·화면 표기는 유지 가능).- 전환기 동작: negodata 적용 전까지는 구 방식 값이 계속 박제된다 — 판정(§4.3)은 박제된 anchor 기준이므로 표본·조정은 그동안에도 유효하게 쌓이고, negodata 적용 시점부터 조정된 rate 가 실제 기준가에 반영되기 시작한다(자연 부트스트랩, 별도 마이그레이션 불필요).
9.2 협상 채팅 — backend (직접 구현, anchoring 모듈 무의존)
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 를 그대로 사용.
→ negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로.
→ 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제)
2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그.
박제하지 않는다 → 이 세션은 anchor 박제가 없어 배치 판정에서 자동 EXCLUDED (학습 무오염).
agent 에는 양수 anchor 가 보장되어 기존 검증(ValueError) 안전.
3. agent 컨텍스트로 anchor_price 전달 (기존 AgentChatContext.anchor_price 그대로)
가격 흔적 기록 (MUST):
- agent 는 변경하지 않는다. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다.
- backend
send()가 가격 입력 턴(price is not None)의 봇 메시지를 저장하는 트랜잭션에UPDATE sessions SET last_offered_price = :price WHERE session_id = :id를 함께 넣는다 — 메시지 저장과 원자적, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3). - 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST).
10. 참조 구현
# src/anchoring/service.py (순수 함수만 — DB/Redis 접근 없음)
from anchoring.constants import (
ANCHOR_RATE_MIN, ANCHOR_RATE_MAX, DELTA_PERMILLE,
SAMPLE_THRESHOLD, PRICE_BRACKET_UNIT, BRACKET_INDEX_MAX,
AnchoringSampleType,
)
from anchoring.base_table import get_base_rate_permille # 정적 테이블 조회 (§2)
def calc_bracket_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 1억 이상은 마지막 인덱스로 클램프.
정적 테이블 idx = 반환값 + 1"""
return min(target_price // PRICE_BRACKET_UNIT, BRACKET_INDEX_MAX)
def calc_anchor_price(target_price: int, rate_permille: int) -> int:
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
return target_price * (1000 - rate_permille) // 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 (박제 앵커)
) -> int:
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적."""
if anchor_price is None or last_offered_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,
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
supplier_type: int, # SMALLINT 코드 1/2/3
) -> int | None:
"""누적 전량 평가. §4.4
반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월)
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
"""
n = len(sample_types)
if n < SAMPLE_THRESHOLD:
return None
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
delta = DELTA_PERMILLE[supplier_type]
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
if success * 10 >= n * 6:
adjusted = rate_before + delta
elif success * 10 < n * 3: # r < 0.30
adjusted = rate_before - delta
else: # 0.30 ≤ r < 0.60
adjusted = rate_before
return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted))
def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int:
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
if latest_adjusted_rate is not None:
return latest_adjusted_rate
return get_base_rate_permille(bracket_index) # int(anchoring_value * 1000)
-- 배치의 미처리 세션 스캔 (§8 절차 1)
SELECT s.session_id, s.status, s.bid_price,
s.target_price, s.target_anchoring_price, s.anchor_rate_permille,
s.last_offered_price, q.supplier_type, i.company_id
FROM negotiation.sessions s
JOIN quotation.quotations q ON q.qt_id = s.quotation_id AND q.deleted = false
JOIN partner.items i ON i.item_id = s.item_id
WHERE s.anchoring_adjustment_id IS NULL
AND s.deleted = false
AND s.qt_type = 1
AND s.status IN (3, 4, 5)
-- 현재 값 조회 (캐시 미스 시)
SELECT anchor_rate_after
FROM anchoring.rate_adjustments
WHERE company_id = :c AND supplier_type = :p AND price_bracket_index = :b
ORDER BY id DESC
LIMIT 1
11. 검증 벡터 (Golden Tests)
아래 케이스가 전부 통과해야 한다 (pytest 고정). 위치: 골든 벡터·배치 통합 = schedules/anchoring/tests/, 가격 흔적 기록·NULL 폴백 = backend/tests/.
11.1 앵커링가 계산 (내림 검증)
| target_price | rate(‰) | 계산 | anchor_price |
|---|---|---|---|
| 30,000 | 200 | 30,000 × 800 // 1000 | 24,000 |
| 26,706 | 10 | 26,706 × 990 // 1000 = 26,438.94 → 내림 | 26,438 |
| 29,999 | 15 | 29,999 × 985 // 1000 = 29,549.015 → 내림 | 29,549 |
| 0 | 10 | 0 | 0 |
11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)
| target_price | bracket_index | 정적 테이블 idx | upper_bound |
|---|---|---|---|
| 0 | 0 | 1 | 3,000 |
| 2,999 | 0 | 1 | 3,000 |
| 3,000 | 1 (경계는 상위 구간) | 2 | 6,000 |
| 99,999,000 | 33,333 (마지막 구간 진입) | 33,334 | 100,000,000 |
| 100,000,000 | 33,333 | 33,334 | 100,000,000 |
| 150,000,000 | 33,333 (1억 초과 → 마지막 인덱스 클램프) | 33,334 | 100,000,000 |
정적 테이블 검증: 33,334행 · idx 1..33334 연속 · upper_bound == min(idx*3000, 100_000_000) · 마지막 행만 100,000,000.
11.3 누적 전량 평가 (유통 코드1, δ=20, rate_before=10)
| pending 구성 | n | r | 판정 | anchor_rate_after |
|---|---|---|---|---|
| 성공 8 / 실패 5 | 13 | ≈ 0.615 | ≥ 0.60 → +20 | 30 |
| 성공 7 / 실패 6 | 13 | ≈ 0.538 | 유지 | 10 |
| 성공 3 / 실패 10 | 13 | ≈ 0.231 | < 0.30 → −20 | 10 (하한 clamp) |
| 성공 6 / 실패 4 | 10 | 0.60 정확히 | 경계 포함 → +20 | 30 |
| 성공 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).
clamp·격리 케이스:
| 시나리오 | 기대 |
|---|---|
| rate_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 | 기대 |
|---|---|---|---|---|
| DONE | 24,000 | 24,000 | 24,000 | BID_SUCCESS (같아도 성공) |
| DONE | 24,001 | 24,001 | 24,000 | BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등) |
| REJECTED | NULL | 25,000 | 24,000 | BID_FAIL (가격 쓰고 결렬) |
| NOT_PARTICIPATED (일괄마감) | NULL | 25,000 | 24,000 | BID_FAIL (가격 쓰고 중간 이탈) |
| 임의 종료 상태 | NULL | NULL | 24,000 | EXCLUDED (가격 흔적 없음) |
| DONE | 24,000 | 24,000 | NULL | EXCLUDED (앵커 박제 없음) |
11.5 배치 멱등성·이월·소비 (DB 통합 — 세션 시드 기반)
| 시나리오 | 기대 |
|---|---|
| 유효 13건 시드 → 배치 | 조정 1행(n=13, consumed_session_ids 13개 박제, 10→30) + 13건 모두 anchoring_adjustment_id=조정 id |
| 직후 배치 재실행 | 조정 0건 (전 칸 pending < 10 — 마킹 멱등) |
| 2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 | 4주 차 배치에서 13건 전량 1회 평가 |
| 배치 1회 누락 → 다음 배치 | 4주치 pending으로 1스텝 평가, 별도 보정 불필요 |
| supplier_type NULL 세션 | 집계 제외 + 마킹 0, 이후 배치에서 재스캔 안 됨 |
| 세션 종료가 배치 스캔 직후 커밋 | 마킹 안 됐으므로 다음 배치에서 정상 소비 (영구 누락 없음) |
| 마킹 rowcount ≠ n (경합 시뮬레이션: pending 일부를 미리 마킹) | 조정 INSERT 포함 전체 롤백 — 조정 0건, 이중 조정 없음 |
| 배치 2개 프로세스 동시 실행(오설정 시뮬레이션) | 한쪽만 조정 성공, 다른 쪽은 rowcount 불일치 롤백 → 칸당 조정 정확히 1건 |
| Redis 에 옛 rate 를 심고 주간 잡 실행(격주 게이트 OFF 주) | 절차 0.5 re-SET 으로 최신 rate 로 회복 |
| 가격 제시율 0% 상태에서 배치 실행 | 요약 로그에 WARN 출력 (backend 기록 배선 유실 감지) |
11.6 읽기 경로·가격 흔적 (E2E 스모크)
| 시나리오 | 기대 |
|---|---|
| 재협상 채팅 → 가격 입력 턴 | last_offered_price 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) |
| 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | last_offered_price 보존 → 배치에서 BID_FAIL 표본 |
| 같은 세션에서 배치가 값 변경 후 다음 턴 | 앵커 불변 (박제값 사용) |
| 박제 없는 세션(NULL 폴백) | anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작 |
| Redis 정지 상태에서 견적 생성(negodata reader) | DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백) |
12. 금지·봉인 사항
구현자가 임의로 추가·가정하면 안 되는 항목:
- 극희소 칸 fallback (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현).
- 정적 기본 테이블 변경 — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
- 조정 이력의 UPDATE/DELETE, 소급 무효화·보정 — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
- sessions 판정 입력 컬럼(
target_anchoring_price,anchor_rate_permille)의 사후 수정,last_offered_price의 종료 후 수정 — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은anchoring_adjustment_id단 하나. - 파라미터 동적 조정 (δ, 경계 60/30, clamp 10/200, 임계 10건, 구간 3,000원, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
- 성공률 외 신호 반영 (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
- 회사 간 값·표본 공유 또는 전사 통합 평가 — 칸은 회사별 완전 독립.
- float 산술 — 앵커링가·rate 계산에 부동소수점 사용 금지 (
round(target*0.99)패턴 금지). - 앵커가 노출 — 앵커가를 협력사 화면에 표시하는 변경은 판정 의미론(§4.3의 무편향 전제)까지 바꾸는 정책 재확정 사안.
13. 선행·연계 작업
담당 구분: [우리] = backend/schedules 직접 구현(완료), [인수인계] = 모듈·명세를 전달 → 담당 개발자가 적용.
| # | 항목 | 담당 | 상태 |
|---|---|---|---|
| 1 | DDL — anchoring.rate_adjustments + sessions 3컬럼 ALTER |
[우리 — 모듈] schema.sql, psql 적용 시점 협의 |
구현 완료 (§6) |
| 2 | 세션 생성 시 앵커 산출을 새 시스템으로 교체 — _build_quotation 앵커 계산 교체 + 재생성 상속 폐지 |
[인수인계 — negodata] | §9.1. reader 는 모듈(async)에서 그대로 이식 |
| 3 | agent | 변경 없음 | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 |
| 4 | 재협상 식별 | — | sessions.qt_type = 1 로 판별 (확인됨) |
| 5 | company_id 식별 | — | partner.items.company_id (세션→item 조인, 기존 _agent_context 해석 방식과 동일) |
| 6 | quotations.supplier_type 기록 |
[인수인계 — negodata] | 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0) |
| 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 읽기 제거(컬럼은 유지). 구현 완료 |