feat(anchoring): 조회용 뷰 2종 신설 — records 테이블은 검토 후 기각 (TODO 2)

요구(회사별 앵커링 값 업데이트 리스트업 + 이전 값 판별)는 rate_adjustments
한 행에 anchor_rate_before→after 가 박제되어 이미 충족 — 신규 테이블은 동일
정보의 사본만 만들므로 기각하고, 조회를 제품화하는 파생 뷰로 해결:

- anchoring.rate_history: 값 변경 이력 리스트업(이전→새 값, delta_permille,
  success_rate, created_at)
- anchoring.current_rates: 칸별 현재값(최신 조정 행 — 없는 칸 = 시작값 10‰)

뷰는 상태가 없어 오염·재구축 이슈 자체가 없고 append-only 보호 대상 아님.
통합 테스트에 뷰 검증 추가(이력 before/after·성공률, 현재값). 운영 문서 §8
쿼리를 뷰 기반으로 단순화, TODO 과제 2 종결(대시보드 페이징 요구 시 스냅샷
테이블 승격 재검토 명시).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-02 20:22:25 +09:00
parent e1940c69aa
commit a132dac57a
5 changed files with 70 additions and 29 deletions

View File

@ -14,30 +14,14 @@
산식 = `bisect_right`. 조정 이력 0건 시점에 적용해 마이그레이션 없음.
상세: `docs/개발용.md` §2·§4.1.
## 2. `anchoring_records` — 회사별 앵커링 값 전체 조회 테이블
## ~~2. `anchoring_records` — 회사별 앵커링 값 전체 조회 테이블~~ ✅ 뷰로 종결 (2026-07-02)
### 배경
**신규 테이블 없이 조회용 뷰 2개로 해결.** 요구(회사별 값 업데이트 리스트업 + 이전 값 판별)는
`rate_adjustments` 한 행에 before→after 가 박제되어 있어 이미 충족 — 테이블 추가는 사본만 만든다고
판단해 기각하고, 조회를 제품화하는 뷰를 추가했다:
현재 `anchoring.rate_adjustments`**"조정 사건" 로그**다 — 값이 바뀐 칸만 행이 생긴다.
"A사의 모든 칸(또는 운영 중 사용된 칸)의 앵커링 값이 지금/과거에 얼마였나"를 한 번에
조회하려면, 조정 이력 + 정적 테이블 시작값을 합성해야 해서 조회(대시보드·관리 화면)에
불편하다. 회사별로 운영되며 업데이트된 모든 앵커링 값을 바로 조회할 수 있는 테이블이 필요하다.
- `anchoring.rate_history` — 회사별 값 변경 이력(이전→새 값, 변화폭, 성공률, 시각)
- `anchoring.current_rates` — 칸별 현재값(없는 칸 = 시작값 10‰)
### 설계 시 결정해야 할 것 (구현이 크게 바뀔 수 있음)
- [ ] **성격 확정**: ① 칸별 "현재값" 스냅샷(UPSERT — 조회 최적) ② 값 변경 시계열의 조회
친화 사본(append) ③ 둘 다 — 무엇이 필요한지 조회 요구사항(대시보드/API 화면)부터 정의
- [ ] **rate_adjustments 와의 역할 분리**: records 는 **파생(조회용) 테이블**로 두고
진실 원천은 rate_adjustments 유지가 안전 — records 는 언제든 이력에서 재구축 가능해야
하며, append-only 보호 대상(§12)에는 포함하지 않는다
- [ ] **쓰기 주체·시점**: 배치가 조정 트랜잭션에 함께 기록할지(정합 우선), 커밋 후
best-effort 로 기록할지(조정 경로 무손대 우선 — 재구축 가능하므로 후자도 충분)
- [ ] **무조정 칸 표현**: 시작값(10‰) 그대로인 칸을 행으로 둘지(회사 온보딩 시 46행 선생성 —
사다리 개편으로 부담 미미), 조회 시 정적 테이블과 합성할지
- [x] ~~과제 1(구간 개편)과 순서 조율~~ — 구간 개편 완료(46칸 사다리), records 는 새 인덱스 기준으로 설계하면 됨
### 참고
- 임시로는 현행 SQL 조회로 동일 정보를 얻을 수 있다: `docs/운영및유지보수.md` §8
(값 변천사 = ①, 현재값 = 칸별 최신 행 + 없으면 시작값 10‰)
- 스펙 반영 위치: `docs/개발용.md` §6(스키마)·§8(배치 절차에 기록 단계 추가)
상세: `docs/개발용.md` §6.3, 사용법: `docs/운영및유지보수.md` §8.
추후 대시보드에서 "전체 칸 나열(무조정 칸 포함)·페이징" 요구가 생기면 그때 스냅샷 테이블로 승격을 재검토한다.

View File

@ -341,6 +341,18 @@ CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
WHERE anchoring_adjustment_id IS NULL AND deleted = false;
```
### 6.3 조회용 뷰 (파생 — 상태 없음)
회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 rate_adjustments 그대로):
```sql
anchoring.rate_history -- 값 변경 이력 리스트업: 이전 값(anchor_rate_before)→새 값 + delta_permille·success_rate·created_at
anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
```
- 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다.
- "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 rate_adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다.
주의사항:
- `sessions.target_anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님).

View File

@ -190,12 +190,14 @@ docker logs anchoring | tail -20 # 최근 상태
로그는 로테이션되지만 **DB 이력은 영구**입니다. "왜 이 값이 됐는가"는 항상 DB로 답할 수 있습니다.
```sql
-- ① 어떤 회사의 값 변천사 (시간순)
SELECT id, created_at, supplier_type, price_bracket_index,
nego_count, success_count, anchor_rate_before, anchor_rate_after
FROM anchoring.rate_adjustments
-- ① 어떤 회사의 값 변천사 (시간순) — 이전 값→새 값·변화폭·성공률까지 한 줄에
SELECT * FROM anchoring.rate_history
WHERE company_id = '<uuid>'
ORDER BY id;
ORDER BY adjustment_id;
-- ①-b 어떤 회사의 칸별 "현재값" 한눈에 (여기 없는 칸 = 시작값 1%)
SELECT * FROM anchoring.current_rates
WHERE company_id = '<uuid>';
-- ② 특정 조정(adj_id)의 근거가 된 협상들
SELECT s.session_id, s.status, s.target_anchoring_price, s.last_offered_price, s.bid_price

View File

@ -37,3 +37,34 @@ ALTER TABLE negotiation.sessions
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
ON negotiation.sessions (qt_type, status)
WHERE anchoring_adjustment_id IS NULL AND deleted = false;
-- ============================================================
-- 조회용 뷰 (파생 — 상태 없음, 진실 원천은 rate_adjustments)
-- ============================================================
-- 회사별 앵커링 값 변경 이력 리스트업: "언제, 어떤 칸이, 몇 건 중 몇 건 성공으로, 몇 ‰에서 몇 ‰로"
CREATE OR REPLACE VIEW anchoring.rate_history AS
SELECT id AS 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,
success_count,
round(success_count::numeric / nego_count, 3) AS success_rate,
created_at
FROM anchoring.rate_adjustments;
-- 칸별 현재값: 칸의 최신 조정 행. 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
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;

View File

@ -78,6 +78,18 @@ async def test_full_cycle_and_idempotency(seeder, caplog):
async with adb.session_scope() as db:
assert len(await _adjustments(db, seeder)) == 1
# 조회용 뷰 — rate_history(이전→새 값 리스트업) / current_rates(칸별 현재값)
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)
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"),
{"c": seeder.company_id, "b": BRACKET})).scalar_one()
assert cur == 30
# ── §11.5: 이월(7건 스킵 → 누적 13건 단일 평가) ──────────
async def test_carryover(seeder):