feat(anchoring): 앵커링 값 자동 조정 자립 모듈 신설 (v1.2)
schedules/anchoring — backend 를 import 하지 않는 독립 컨테이너 배치 서비스.
회사 × 협력사유형(1유통/2제조/3총판) × 가격구간(3,000원, 33,334칸)별 앵커링
값(정수 천분율)을 격주 토 00:00 KST 에 협상 성공률로 자동 조정한다.
- 판정 = "가격 흔적" 기준: last_offered_price 가 있는 종료 재협상만 표본,
DONE & bid ≤ 박제 앵커만 성공, 나머지(초과 합의·결렬·가격 쓰고 이탈) 실패.
앵커는 비노출(엔진 내부 체결 임계) — agent 무변경
- 저장 = anchoring.rate_adjustments 1개(append-only, consumed_session_ids 박제),
소비 경계 = sessions.anchoring_adjustment_id 마킹(멱등·이월). DDL 은 모듈
소유(schema.sql, sessions 3컬럼 ALTER 포함)
- 안정성: 조정 INSERT+마킹 한 트랜잭션 + rowcount 불일치 전체 롤백,
Redis TTL 7일 + 매주 조정 칸 re-SET, socket timeout 0.3s, DB 폴백,
가격 제시율 0% WARN, --once 수동 캐치업
- 정적 기본 테이블(전 구간 10‰, 상한 정확히 1억·초과분 마지막 인덱스 클램프)
기동 검증 실패 시 기동 중단
- 전체 async(SQLAlchemy+asyncpg, redis.asyncio) — negodata 가 reader 를 그대로
이식 가능(docs/인수인계.md). 최종 문서 docs/{개발용,기획용,워크플로우}.md
- 테스트 15종: 골든 벡터(§11) + DB 통합(멱등·이월·격리·rowcount 롤백)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0ac37625fc
commit
f29e9723b0
5
schedules/anchoring/.gitignore
vendored
Normal file
5
schedules/anchoring/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
config.toml
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
14
schedules/anchoring/Dockerfile
Normal file
14
schedules/anchoring/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY config.toml* ./
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app/src \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
CMD ["python", "-m", "anchoring.main"]
|
||||||
60
schedules/anchoring/README.md
Normal file
60
schedules/anchoring/README.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# anchoring — 앵커링 값 자동 조정 배치 (자립 모듈)
|
||||||
|
|
||||||
|
회사 × 협력사유형(1유통/2제조/3총판) × 가격구간(3,000원, 33,334칸)별 앵커링 값(‰)을
|
||||||
|
**격주 토 00:00 KST** 배치로 협상 성공률에 따라 자동 조정한다.
|
||||||
|
`schedules/anchoring` 아래에서 **완전 독립**으로 동작 — backend 코드를 import 하지 않는다.
|
||||||
|
|
||||||
|
> 규범 문서: **`docs/개발용.md`** (정책: `docs/기획용.md`, 흐름 해설: `docs/워크플로우.md`, 타 팀 적용: `docs/인수인계.md`)
|
||||||
|
|
||||||
|
## 경계
|
||||||
|
|
||||||
|
| 구분 | 대상 |
|
||||||
|
|---|---|
|
||||||
|
| 소유(쓰기) | `anchoring.rate_adjustments`(append-only 조정 이력), `sessions.anchoring_adjustment_id`(소비 마킹 — 이 컬럼만), Redis `anchor:*` 키 |
|
||||||
|
| 읽기 전용 | `negotiation.sessions`(박제 컬럼), `quotation.quotations.supplier_type`, `partner.items.company_id` |
|
||||||
|
| 소비자 | negodata 가 `reader.get_anchor_rate` 이식 + 이 Redis 를 참조해 세션 생성 시 앵커가 박제 (인수인계) |
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
schema.sql # 모듈 소유 DDL (rate_adjustments + sessions 3컬럼) — psql 수동 적용
|
||||||
|
src/anchoring/
|
||||||
|
constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의)
|
||||||
|
resources/anchoring_base.json # 정적 기본 테이블(33,334칸, 전부 10‰) — 불변, 시작값의 유일한 소스
|
||||||
|
base_table.py # 로드+검증(실패 시 기동 중단)
|
||||||
|
service.py # 순수 계산 (구간·앵커가·판정·평가) — negodata 이식 대상
|
||||||
|
reader.py # 현재 rate 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상
|
||||||
|
redis_client.py # TTL 7일, socket timeout 0.3s, 장애 시 DB 폴백
|
||||||
|
batch.py # 격주 평가: 캐시 re-SET → 스캔·파생 판정 → 조정 INSERT+마킹(한 트랜잭션, rowcount 롤백)
|
||||||
|
scheduler.py # 매주 토 00:00 트리거 (격주 게이트는 잡 내부 ISO 주차 홀짝)
|
||||||
|
main.py # 엔트리 (상주 / --once)
|
||||||
|
tests/ # 골든 벡터(test_core) + DB 통합(test_batch — 로컬 Postgres 없으면 자동 스킵)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0) DDL 적용 (신규 DB: postgres-init/01~04 이후)
|
||||||
|
psql -h 127.0.0.1 -U postgres -d negosium_db -f schema.sql
|
||||||
|
|
||||||
|
# 로컬(가상환경)
|
||||||
|
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
|
cp config.toml.example config.toml # DB/Redis 채우기 (env 로 대체 가능)
|
||||||
|
PYTHONPATH=src .venv/bin/python -m anchoring.main --once # 수동 1회(격주 게이트 무시)
|
||||||
|
PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주
|
||||||
|
|
||||||
|
# 도커(자립 compose: redis 동봉)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 테스트
|
||||||
|
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## 운영 런북
|
||||||
|
|
||||||
|
- **미스파이어**: 토 00:00 에 서비스가 내려가 있었고 1시간(misfire_grace) 초과로 그 회차가 스킵됐다면,
|
||||||
|
재기동 후 `--once` 1회 실행으로 즉시 캐치업(격주 게이트만 무시, 정책 파라미터 불변).
|
||||||
|
- **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고
|
||||||
|
TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`.
|
||||||
|
- **노출률 0% WARN**: agent 스크립트 스텝명(`기존가격제시`) 변경이나 backend 노출 기록 배선 유실 신호 — 즉시 점검.
|
||||||
|
- 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `anchoring_adjustment_id` 하나뿐.
|
||||||
17
schedules/anchoring/config.toml.example
Normal file
17
schedules/anchoring/config.toml.example
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# anchoring 모듈 설정 — config.toml 로 복사 후 채운다 (config.toml 은 gitignore).
|
||||||
|
# 우선순위: env(DB_*/REDIS_*/LOG_LEVEL) > 이 파일 > 코드 기본값.
|
||||||
|
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
[db]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 5432
|
||||||
|
user = "postgres"
|
||||||
|
password = "postgres"
|
||||||
|
name = "negosium_db"
|
||||||
|
|
||||||
|
[redis]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 6379
|
||||||
|
db = 0
|
||||||
|
password = ""
|
||||||
20
schedules/anchoring/docker-compose.yml
Normal file
20
schedules/anchoring/docker-compose.yml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# anchoring 자립 서비스 — 루트 compose 와 독립(다른 서버를 건드리지 않음).
|
||||||
|
# DB 는 기존 외부 PostgreSQL(host.docker.internal), Redis 는 여기 동봉.
|
||||||
|
# negodata(견적 생성 측)는 이 redis 인스턴스를 REDIS_HOST 로 바라본다(docs/인수인계.md).
|
||||||
|
services:
|
||||||
|
anchoring-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: anchoring-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
anchoring:
|
||||||
|
build: .
|
||||||
|
container_name: anchoring
|
||||||
|
environment:
|
||||||
|
DB_HOST: host.docker.internal
|
||||||
|
REDIS_HOST: anchoring-redis
|
||||||
|
depends_on:
|
||||||
|
- anchoring-redis
|
||||||
|
restart: unless-stopped
|
||||||
656
schedules/anchoring/docs/개발용.md
Normal file
656
schedules/anchoring/docs/개발용.md
Normal file
@ -0,0 +1,656 @@
|
|||||||
|
# 앵커링 시스템 구현 스펙 (개발용)
|
||||||
|
|
||||||
|
> **문서 성격**: 이 문서만 보고 앵커링 시스템을 구현·유지보수할 수 있도록 작성된 규범 문서(최종 확정본).
|
||||||
|
> **규범 언어**: `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` 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [확정 결정 요약](#1-확정-결정-요약)
|
||||||
|
2. [정적 기본 테이블](#2-정적-기본-테이블)
|
||||||
|
3. [상수 정의](#3-상수-정의)
|
||||||
|
4. [도메인 규칙](#4-도메인-규칙)
|
||||||
|
5. [아키텍처](#5-아키텍처)
|
||||||
|
6. [DB 스키마](#6-db-스키마)
|
||||||
|
7. [Redis 캐시 규약](#7-redis-캐시-규약)
|
||||||
|
8. [배치 잡 명세](#8-배치-잡-명세)
|
||||||
|
9. [견적 생성·협상 플로우](#9-견적-생성협상-플로우)
|
||||||
|
10. [참조 구현](#10-참조-구현)
|
||||||
|
11. [검증 벡터 (Golden Tests)](#11-검증-벡터-golden-tests)
|
||||||
|
12. [금지·봉인 사항](#12-금지봉인-사항)
|
||||||
|
13. [선행·연계 작업](#13-선행연계-작업)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "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%`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 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):
|
||||||
|
|
||||||
|
```python
|
||||||
|
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 조정 이력 (신설 — 유일한 새 테이블)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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)
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
-- 배치 스캔 최적화: 미처리 세션만
|
||||||
|
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/PASSWORD` env 오버라이드. 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 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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. 참조 구현
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 배치의 미처리 세션 스캔 (§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)
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 현재 값 조회 (캐시 미스 시)
|
||||||
|
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` 읽기 제거(컬럼은 유지). 구현 완료 |
|
||||||
224
schedules/anchoring/docs/기획용.md
Normal file
224
schedules/anchoring/docs/기획용.md
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
# 앵커링 값 자동 조정 시스템 — 정책 안내서 (기획/비개발자용)
|
||||||
|
|
||||||
|
> **한 줄 요약**: 협상에서 "이 가격 이하면 합의한다"는 우리 쪽 기준선을, 시장의 반응을 보면서 시스템이 스스로 조금씩 조절해 나가는 장치입니다. 사람이 일일이 정하지 않아도, 협상 결과가 쌓일수록 "너무 세지도, 너무 약하지도 않은" 적정 강도를 자동으로 찾아갑니다.
|
||||||
|
>
|
||||||
|
> **버전**: v1.2 (2026-07-02 확정) — 기술 상세는 `개발용.md`, 흐름 해설은 `워크플로우.md` 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
- [1. 앵커링이 뭔가요?](#1-앵커링이-뭔가요)
|
||||||
|
- [2. 시스템이 관리하는 단위: "칸"](#2-시스템이-관리하는-단위-칸)
|
||||||
|
- [3. 작동 원리 — 흥정에 비유하면](#3-작동-원리--흥정에-비유하면)
|
||||||
|
- [4. 규칙 상세](#4-규칙-상세)
|
||||||
|
- [5. 숫자로 따라가 보는 예시 시나리오](#5-숫자로-따라가-보는-예시-시나리오)
|
||||||
|
- [6. 협상이 중간에 끝난 경우는요?](#6-협상이-중간에-끝난-경우는요)
|
||||||
|
- [7. 왜 이렇게 설계했나요?](#7-왜-이렇게-설계했나요)
|
||||||
|
- [8. 자주 나오는 질문 (FAQ)](#8-자주-나오는-질문-faq)
|
||||||
|
- [9. 용어 정리](#9-용어-정리)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 앵커링이 뭔가요?
|
||||||
|
|
||||||
|
협상에는 "처음 제시된 숫자가 기준점이 되어 이후 대화 전체를 끌어당긴다"는 심리 효과가 있습니다. 이를 **앵커링(닻 내리기)** 이라고 부릅니다. 배가 닻을 내린 자리 주변에서 움직이듯, 협상도 첫 제안 근처에서 타결되는 경향이 있죠.
|
||||||
|
|
||||||
|
NegoWiz에서는 목표가에서 일정 비율을 깎은 가격을 **합의 기준선(앵커링가)** 으로 삼습니다. 이때 **몇 % 깎을지**가 바로 **앵커링 값**입니다.
|
||||||
|
|
||||||
|
> **앵커링가(기준가) = 목표가 × (1 − 앵커링 값)**, 소수점은 버리고 1원 단위까지 계산
|
||||||
|
>
|
||||||
|
> 예) 목표가 30,000원, 앵커링 값 3% → 기준가 **29,100원** — 협력사가 29,100원 이하를 써내면 그 가격으로 합의
|
||||||
|
|
||||||
|
앵커링 값은 항상 **1% ~ 20%** 사이에서만 움직입니다. 시작값은 시스템에 내장된 기준표에 정해져 있으며, 현재는 모든 가격구간에서 **1%** 입니다. 이 기준표는 절대 바뀌지 않는 고정값이고, 실제 운영에서 쓰이는 값은 협상 결과에 따라 이 시작점에서부터 움직여 갑니다.
|
||||||
|
|
||||||
|
**적용 대상**: 이 시스템이 값을 산출하고 그 결과를 학습(표본 수집·값 조정)하는 대상은 **재협상(1:1)** 건입니다. 여러 협력사가 동시에 참여하는 재견적(1:N) 등 다른 유형의 결과는 값 조정에 사용하지 않습니다.
|
||||||
|
|
||||||
|
**중요 — 앵커링가는 협력사에게 보여주지 않습니다**: 계산된 앵커링가는 협력사 화면에 표시되지 않고, 협상 챗봇이 **합의 가능 여부를 판단하는 내부 기준선**으로만 동작합니다. 협력사가 스스로 써낸 가격이 이 기준선 이하이면 그 가격으로 합의가 성사됩니다. 기준선을 숨기는 이유: 상대가 우리 한계를 모르는 채 먼저 가격을 부르게 하면 (1) 기준선보다 더 싸게 낼 의향이 있던 협력사의 가격을 그대로 얻고(보여주면 딱 그 값에 맞춰 냅니다), (2) 상대의 가격 정보를 먼저 확보하는 협상 우위를 유지할 수 있기 때문입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 시스템이 관리하는 단위: "칸"
|
||||||
|
|
||||||
|
"모든 협상에 똑같은 %를 적용"하면 안 되는 이유가 있습니다. 3천 원짜리 물건과 5천만 원짜리 물건은 흥정의 여유가 다르고, 유통사와 제조사는 가격을 받아들이는 태도가 다르며, **회사가 다르면 거래하는 협력사와 협상 환경 자체가 다르기** 때문입니다.
|
||||||
|
|
||||||
|
NegoWiz는 여러 회사가 함께 쓰는 플랫폼이므로, 시스템은 협상을 세 기준으로 분류한 **칸(cell)** 단위로 앵커링 값을 따로 관리합니다.
|
||||||
|
|
||||||
|
| 기준 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| **회사** | 플랫폼을 쓰는 각 고객사. 회사끼리는 값도 협상 기록도 완전히 분리 |
|
||||||
|
| **가격구간** | 목표가를 3,000원 단위로 나눈 구간 (0원 ~ 1억 원, 총 33,334개). **1억 원을 넘는 목표가는 전부 마지막 구간(1억 원 구간)으로 편입** |
|
||||||
|
| **협력사 유형** | 유통 / 총판 / 제조 |
|
||||||
|
|
||||||
|
즉 "A사의 유통 3만 원대"와 "B사의 유통 3만 원대"는 **서로 다른 칸**이고, 각자 자기만의 앵커링 값과 협상 기록을 가집니다. A사의 협상 결과가 B사의 값에 영향을 주는 일은 없습니다. 새 회사가 플랫폼에 들어오면 모든 칸이 기준표의 시작값(1%)에서 출발합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 작동 원리 — 흥정에 비유하면
|
||||||
|
|
||||||
|
시장에서 단골 도매상과 매일 거래하는 상인을 떠올려 보세요.
|
||||||
|
|
||||||
|
- 처음 거래하는 상대에게는 **조심스럽게 아주 조금만** 깎아 부릅니다. (시작 1%)
|
||||||
|
- 깎아 불렀는데도 **상대가 계속 받아주면**, "조금 더 깎아도 되겠는데?" 하고 다음부터 **조금 더 세게** 부릅니다.
|
||||||
|
- 반대로 **거절이 잦아지면**, "너무 셌구나" 하고 **한발 물러섭니다**.
|
||||||
|
- 받아주는 비율이 **적당한 수준이면 그대로 유지**합니다. 굳이 건드리지 않습니다.
|
||||||
|
|
||||||
|
이 시스템은 정확히 이 상인의 감각을 규칙으로 만든 것입니다. 다만 사람과 달리 회사별 수만 개의 칸을 전부 동시에, 감정 없이, 데이터로만 판단합니다.
|
||||||
|
|
||||||
|
중요한 특징 하나: **어디까지 깎을 수 있을지는 시스템이 정하는 게 아니라 시장(협력사들)이 정합니다.** 시스템은 상대가 받아주는 한계선을 더듬어 찾아갈 뿐입니다. 그래서 이 값은 "우리가 정한 목표"가 아니라 "시장이 알려준 답"에 가깝습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 규칙 상세
|
||||||
|
|
||||||
|
### 언제 조정하나요? — "2주마다, 10건이 모였다면"
|
||||||
|
|
||||||
|
값 조정은 **격주 토요일 자정(00시)** 에 정기적으로 이루어집니다. 이때 각 칸을 살펴서:
|
||||||
|
|
||||||
|
- 지난 조정 이후 협상 결과가 **10건 이상** 모였으면 → 평가하고 값을 조정합니다.
|
||||||
|
- **10건 미만**이면 → 이번에는 건너뛰고, 모인 결과를 그대로 들고 다음 주기로 넘어갑니다.
|
||||||
|
|
||||||
|
건너뛴 칸은 다음 조정일에 **4주치**를 보게 되고, 그래도 부족하면 6주, 8주… 로 자연스럽게 기간이 늘어납니다. 거래가 드문 칸도 결국 10건이 차는 시점에 반드시 평가됩니다. "몇 주치인가"는 중요하지 않고 **"10건 이상 모였는가"** 만 봅니다.
|
||||||
|
|
||||||
|
> 참고: "격주"는 시스템 달력(주차의 홀짝) 기준입니다. 달력 특성상 수년에 한 번꼴로 조정 간격이 한 차례 3주가 될 수 있는데, 그 기간의 결과는 사라지지 않고 다음 조정일에 그대로 합산 평가되므로 실질적인 영향은 없습니다.
|
||||||
|
|
||||||
|
### 무엇을 보나요? — "성공률"
|
||||||
|
|
||||||
|
모인 결과 **전체**에서 **성공**이 얼마나 되는지 봅니다. 13건이 모였으면 13건 전체로 성공률을 계산합니다.
|
||||||
|
|
||||||
|
> **성공** = 협상이 정상적으로 끝났고, 협력사가 써낸 가격이 우리 기준가(앵커링가) **이하**인 경우
|
||||||
|
|
||||||
|
기준가보다 싸게(또는 같게) 들어왔다면, 우리가 정한 기준이 시장에 통했다는 뜻이니까요. 한번 평가에 쓰인 결과는 비워지고, 다음 평가는 새로 모인 결과만 봅니다.
|
||||||
|
|
||||||
|
### 어떻게 조정하나요? — "3단계"
|
||||||
|
|
||||||
|
| 성공률 | 판단 | 조치 |
|
||||||
|
|---|---|---|
|
||||||
|
| **60% 이상** | 잘 통하고 있다 | 앵커링 값을 **올린다** (더 세게) |
|
||||||
|
| **30% ~ 60%** | 적당하다 | **유지** |
|
||||||
|
| **30% 미만** | 너무 셌다 | 앵커링 값을 **내린다** (완화) |
|
||||||
|
|
||||||
|
올리고 내리는 **폭은 유형마다 다릅니다**. 유통이 가장 큰 폭으로 움직이고(±2%p), 총판(±1.5%p), 제조(±1%p) 순입니다. 유통 쪽이 가격 협상의 여지가 커서 더 과감하게 탐색한다는 뜻입니다.
|
||||||
|
|
||||||
|
올리는 폭과 내리는 폭은 **같습니다(대칭)**. 그래서 성공률이 절반 근처에서 왔다 갔다 하는 균형점에 도달하면 값이 자연스럽게 멈춥니다.
|
||||||
|
|
||||||
|
한 칸의 값은 조정일 한 번에 **딱 한 계단**만 움직입니다. 아무리 많은 결과가 쌓여 있어도 한 번에 여러 계단을 뛰어오르지 않으므로, 협력사 입장에서 가격 강도가 갑자기 널뛰는 일이 없습니다.
|
||||||
|
|
||||||
|
어떤 경우에도 값은 **1% 아래로 내려가지 않고, 20% 위로 올라가지 않습니다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 숫자로 따라가 보는 예시 시나리오
|
||||||
|
|
||||||
|
**"A사 × 유통 × 3만 원대" 칸**의 몇 달을 따라가 봅시다. 유통이므로 조정폭은 ±2%p입니다.
|
||||||
|
|
||||||
|
| 조정일 | 모인 결과 | 성공률 | 판단 | 앵커링 값 변화 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 시작 | — | — | — | **1%** (기준표 시작값) |
|
||||||
|
| 1차 (2주 후) | 12건 중 성공 9건 | 75% | 잘 통함 → 올림 | 1% → **3%** |
|
||||||
|
| 2차 (4주 후) | 11건 중 성공 8건 | 73% | 잘 통함 → 올림 | 3% → **5%** |
|
||||||
|
| 3차 (6주 후) | **7건뿐** | — | 10건 미만 → **건너뜀** | **5%** (7건 이월) |
|
||||||
|
| 4차 (8주 후) | 이월 7건 + 새 6건 = 13건 중 성공 8건 | 62% | 잘 통함 → 올림 | 5% → **7%** |
|
||||||
|
| 5차 (10주 후) | 10건 중 성공 2건 | 20% | 너무 셌음 → 내림 | 7% → **5%** |
|
||||||
|
| 6차 (12주 후) | 14건 중 성공 9건 | 64% | 잘 통함 → 올림 | 5% → **7%** |
|
||||||
|
| 7차 (14주 후) | 11건 중 성공 5건 | 45% | 적당함 → 유지 | **7%** |
|
||||||
|
|
||||||
|
3차 조정일을 눈여겨보세요 — 10건이 안 돼서 건너뛰었고, 4차 때 **4주치 13건 전체**로 평가했습니다. 이후로 값은 5~7% 사이에서 잔잔하게 오르내립니다. **이 칸의 시장이 받아주는 한계가 대략 7% 언저리**라는 걸 시스템이 스스로 찾아낸 것입니다. 같은 시기 B사의 유통 3만 원대 칸은 B사 자신의 협상 결과에 따라 전혀 다른 값에 가 있을 수 있습니다.
|
||||||
|
|
||||||
|
합의 기준선이 어떻게 달라지는지 보면:
|
||||||
|
|
||||||
|
| 앵커링 값 | 목표가 30,000원일 때 기준가 |
|
||||||
|
|---|---|
|
||||||
|
| 1% (초기) | 29,700원 |
|
||||||
|
| 7% (수렴 후) | 27,900원 |
|
||||||
|
|
||||||
|
초기에는 사실상 목표가 근처면 합의해 주다가, 학습이 진행되면서 협상 여지를 1,800원 더 확보하게 됩니다.
|
||||||
|
|
||||||
|
**도달 속도는 거래량에 달려 있습니다.** 거래가 활발한 칸은 두 달 안에 균형점 근처에 가고, 한산한 칸은 반년 이상 걸릴 수 있습니다. 하지만 도착하는 **목적지는 같습니다** — 속도만 다를 뿐입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 협상이 중간에 끝난 경우는요?
|
||||||
|
|
||||||
|
모든 협상이 합의까지 가지는 않습니다. 협력사가 가격을 몇 번 써내다가 떠나기도 하고, 아예 참여하지 않은 채 기한이 만료되기도 하죠. 이런 건을 어떻게 셀지가 중요한 정책 결정이었고, 다음과 같이 확정했습니다.
|
||||||
|
|
||||||
|
> 채점 기준 한 줄 요약: **"가격을 한 번이라도 써낸 협상만 세고 — 기준가 이하로 합의됐으면 성공, 나머지는 전부 실패."**
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|---|---|
|
||||||
|
| 기준가 이하로 합의 성사 | **성공** |
|
||||||
|
| 가격을 써냈지만 합의 못 함 — 기준 초과로 마무리, 결렬, **가격을 쓰다가 중간 이탈**(이후 기한만료로 정리된 경우 포함) | **실패**로 카운트 |
|
||||||
|
| 가격을 **한 번도 써내지 않고** 끝남 (미참여·무응답 이탈·취소) | 결과에서 **제외** (카운트 안 함) |
|
||||||
|
|
||||||
|
이렇게 정한 이유: 가격을 써냈다는 건 협상에 실제로 응했다는 뜻이고, 그런데도 우리 기준선 아래로 합의가 안 됐다면 그건 **"기준이 시장보다 세다"는 신호**입니다. 이걸 실패로 세지 않으면, 합의된 건만 남아 성공률이 좋아 보이는 착시가 생기고 시스템이 값을 한계 없이 올리게 됩니다. 반면 가격을 한 번도 써내지 않은 건(담당자 부재, 관심 없음 등)은 — 기준가가 화면에 보이지 않으므로 — 우리 기준의 세기와 무관한 이탈입니다. 판단 재료에서 빼도 왜곡이 없습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 왜 이렇게 설계했나요?
|
||||||
|
|
||||||
|
설계 과정에서 협상 이론 연구와 시뮬레이션을 검토해 내린 결정들입니다.
|
||||||
|
|
||||||
|
**상한이 40%가 아니라 20%인 이유** — 협상 연구(컬럼비아대 Ames & Mason)에 따르면 첫 제안의 효과적인 할인 범위는 5~20%입니다. 그보다 극단적인 제안은 앵커 효과가 사라지고 상대를 협상장 밖으로 밀어냅니다. 그래서 상한을 20%로 정했습니다.
|
||||||
|
|
||||||
|
**올림과 내림의 폭이 같은 이유** — 올리는 폭을 더 크게 하면 값이 위아래로 크게 출렁이며 협상 전략이 불안정해집니다. 대칭으로 맞추면 균형점에서 얌전히 멈춥니다. 시뮬레이션에서 출렁임이 절반으로 줄었습니다.
|
||||||
|
|
||||||
|
**첫 시작이 1%로 소극적인 이유** — 처음부터 세게 나가서 협력사를 잃는 것보다, 낮게 시작해서 시장이 허용하는 만큼 올라가는 쪽이 안전하기 때문입니다. B2B는 반복 거래라 협력사와의 관계가 자산입니다. 대가는 초기 몇 달간 앵커링 이득을 덜 보는 것인데, 이는 의도된 보수적 선택입니다.
|
||||||
|
|
||||||
|
**격주 정기 조정 + 조정일당 한 계단인 이유** — 건건이 가격 강도가 널뛰면 협력사 입장에서 예측 불가능한 상대가 됩니다. 2주라는 통제된 간격, 그리고 한 번에 한 계단이라는 제한이 신뢰를 지킵니다. 또한 최소 10건을 모아 보므로 한두 건의 우연한 결과에 휘둘리지 않습니다.
|
||||||
|
|
||||||
|
**회사별로 값을 분리한 이유** — 회사마다 거래하는 협력사, 상품, 협상 문화가 다르므로 "시장이 알려주는 답"도 회사마다 다릅니다. 섞어서 배우면 어느 회사에도 맞지 않는 어중간한 값이 됩니다.
|
||||||
|
|
||||||
|
**기준가를 숨기는 이유** — 기준선을 보여주면 협력사는 딱 그 값에 맞춰 내게 되어, 더 싸게 낼 의향이 있던 협력사의 가격을 놓칩니다. 숨기면 상대가 먼저 가격을 부르므로 상대의 정보를 얻는 협상 우위도 유지됩니다.
|
||||||
|
|
||||||
|
전체를 관통하는 철학은 하나입니다: **이 시스템의 목표는 "최대한 깎기"가 아니라 "서로 계속 거래할 수 있는 균형점 찾기"입니다.** 지나친 할인으로 성사된 거래는 장기적으로 이탈로 이어진다는 연구 결과도 이 방향을 뒷받침합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 자주 나오는 질문 (FAQ)
|
||||||
|
|
||||||
|
**Q. 사람이 개입해서 값을 바꿀 수 있나요?**
|
||||||
|
현재 정책에서는 없습니다. 값은 오직 협상 결과 데이터로만 움직입니다. 다만 모든 협상 기록과 조정 이력이 보존되므로, "이 칸의 값이 왜 7%가 됐는지"는 이력으로 전부 추적·설명할 수 있습니다.
|
||||||
|
|
||||||
|
**Q. 기준표(시작값 표)와 실제 운영 값은 뭐가 다른가요?**
|
||||||
|
기준표는 "출발선"이고 절대 바뀌지 않습니다. 실제 운영 값은 그 출발선에서 협상 결과에 따라 움직여 온 "현재 위치"입니다. 값이 조정된다는 것은 기준표를 고치는 게 아니라, 조정 이력이 한 줄 더 쌓여 현재 위치가 바뀐다는 뜻입니다.
|
||||||
|
|
||||||
|
**Q. 성공률이 높을수록 좋은 건가요?**
|
||||||
|
아닙니다, 이게 가장 오해하기 쉬운 부분입니다. 성공률은 "성과"가 아니라 **"현재 기준 강도에 대한 시장의 수용도"** 입니다. 앵커링 1%에 성공률 90%보다, 15%에 성공률 50%가 사업적으로 훨씬 좋은 상태입니다. 대시보드를 본다면 성공률 단독이 아니라 앵커링 값과 함께 봐야 합니다. 오히려 성공률이 50% 근처라는 건 **시스템이 균형점을 잘 찾았다**는 신호입니다.
|
||||||
|
|
||||||
|
**Q. 13건이 모였는데 왜 10건만 안 보고 13건을 다 보나요?**
|
||||||
|
표본이 많을수록 성공률 판단이 정확해지기 때문입니다. 그리고 몇 건이 모였든 조정은 한 계단만 이루어지므로, 많이 모였다고 값이 더 크게 움직이지는 않습니다.
|
||||||
|
|
||||||
|
**Q. 거래가 거의 없는 칸은 어떻게 되나요?**
|
||||||
|
10건이 찰 때까지 조정일마다 기간을 늘려가며 기다립니다(2주 → 4주 → 6주…). 극단적으로 거래가 드문 칸은 오래도록 시작값(1%) 근처에 머물 수 있는데, 거래가 없는 칸이니 사업 영향도 작습니다. 인접 가격대의 학습 결과를 빌려오는 보완책이 아이디어로 논의됐지만, **아직 확정하지 않은 미결 과제**입니다. 회사별로 값을 분리하면서 칸당 거래가 더 잘게 나뉘므로, 이 과제는 앞으로 중요해질 수 있습니다.
|
||||||
|
|
||||||
|
**Q. 협력사가 이 시스템의 존재를 알면 역이용하지 않을까요?**
|
||||||
|
일부러 초반에 거절을 반복해 값을 낮추는 시도를 상상할 수 있습니다. 다만 기준가가 화면에 보이지 않고, 값은 조정일에 최대 1~2%p씩만 움직이며 하한이 1%라, 역이용의 이득 대비 거래 포기 비용이 큽니다. 그래도 장기 운영에서 모니터링할 가치는 있는 지점입니다.
|
||||||
|
|
||||||
|
**Q. 목표가 자체를 시스템이 정하는 건가요?**
|
||||||
|
아닙니다. 목표가는 기존 프로세스대로 정해지고, 이 시스템은 그 목표가에서 **합의 기준선을 몇 % 아래에 둘지**만 결정합니다.
|
||||||
|
|
||||||
|
**Q. 파라미터(폭, 경계선, 상한)를 나중에 바꿀 수 있나요?**
|
||||||
|
가능합니다. 모든 협상 기록과 조정 이력이 보존되는 구조라, 규칙을 바꾸면 과거 이력에 새 규칙을 다시 적용해 값을 재계산할 수 있습니다. 다만 파라미터 변경은 정책 재확정 절차를 거쳐야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 용어 정리
|
||||||
|
|
||||||
|
| 용어 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **앵커링 값** | 목표가에서 깎아 기준선을 정하는 비율. 1%~20%, 시작은 기준표 값(현재 1%) |
|
||||||
|
| **앵커링가(기준가)** | 목표가 × (1 − 앵커링 값), 소수점 버림. 협력사에게 표시하지 않는 내부 합의 기준선 |
|
||||||
|
| **기준표** | 가격구간별 시작값이 담긴 불변 표. 서비스에 내장되며 절대 변경되지 않음 |
|
||||||
|
| **칸** | 회사 × 가격구간 × 협력사 유형 조합. 값이 관리되는 최소 단위 |
|
||||||
|
| **가격구간** | 목표가를 3,000원 단위로 나눈 구간 (0~1억 원). 1억 원 초과는 마지막 구간으로 편입 |
|
||||||
|
| **재협상** | 협력사 1곳과 1:1로 진행하는 협상. 이 시스템의 학습(표본 수집·값 조정) 대상 |
|
||||||
|
| **가격 흔적** | 협력사가 협상에서 가격을 한 번이라도 써낸 기록. 가격 흔적이 있는 협상만 채점 대상 |
|
||||||
|
| **성공** | 정상 종료 협상에서 협력사 투찰가 ≤ 기준가(앵커링가) |
|
||||||
|
| **성공률** | 조정일까지 모인 결과 전체 중 성공 비율 |
|
||||||
|
| **조정일** | 격주 토요일 00시. 10건 이상 모인 칸만 평가·조정 |
|
||||||
|
| **이월** | 10건 미만이라 평가를 건너뛰고 결과를 다음 조정일로 넘기는 것 |
|
||||||
|
| **균형점** | 성공률이 절반 근처를 오가며 값이 안정되는 지점. 시장이 정한다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 기술 구현 상세(데이터 구조, 계산 절차, 검증 기준)는 **`개발용.md`**, 시간 순서 해설은 **`워크플로우.md`** 를 참조하세요. 세 문서는 같은 정책(v1.2, 2026-07-02 확정)을 눈높이만 달리해 기술한 것입니다.
|
||||||
118
schedules/anchoring/docs/워크플로우.md
Normal file
118
schedules/anchoring/docs/워크플로우.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# 앵커링 시스템 워크플로우 설명 (비개발자용)
|
||||||
|
|
||||||
|
> **문서 성격**: `개발용.md`의 워크플로우를 비개발자도 이해할 수 있게 풀어 쓴 안내서.
|
||||||
|
> **버전**: v1.2 기준 (2026-07-02) — 정책 배경은 `기획용.md`, 기술 상세는 `개발용.md` 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 한 줄 요약
|
||||||
|
|
||||||
|
**협상마다 "이 가격 이하면 합의한다"는 기준선을 장부에서 찾아 정하고, 협상이 끝날 때마다 결과가 쌓이고, 2주에 한 번 시스템이 그 결과를 채점해서 장부의 숫자를 한 칸씩 조정한다** — 이 순환 구조입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 등장하는 것 4가지
|
||||||
|
|
||||||
|
| 이름 | 비유 | 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| **기준표** (정적 기본 테이블) | 공장 출하 시 기본 설정값 | 모든 칸의 출발점(전부 1%). 절대 안 바뀌는 내장 표 |
|
||||||
|
| **협상 기록** (`negotiation.sessions`) | 협상 한 건 한 건의 계약서 철 | "그때 기준가가 얼마였고, 상대가 얼마를 써냈고, 얼마에 끝났는지"가 적힘 |
|
||||||
|
| **조정 장부** (`anchoring.rate_adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 |
|
||||||
|
| **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 |
|
||||||
|
|
||||||
|
여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대(3천 원 단위, 0원~1억, 총 33,334개)** 조합입니다. 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 워크플로우 — 시간 순서대로
|
||||||
|
|
||||||
|
### ① 서버가 켜질 때
|
||||||
|
|
||||||
|
기준표(33,334개 가격구간 × 시작값 1%)를 메모리에 올리고, 표가 손상됐으면 아예 서버를 켜지 않습니다. 잘못된 가격으로 협상하는 것보다 안 켜지는 게 낫다는 안전장치입니다.
|
||||||
|
|
||||||
|
### ② 견적(협상 건)이 만들어질 때 — "기준선을 정한다"
|
||||||
|
|
||||||
|
재협상 견적이 생성되는 시점에 견적 시스템(negodata)이 이 협상의 칸을 찾습니다. 그 칸의 현재 앵커링 값(깎는 비율)을 빠른 조회판에서 읽고 — 없으면 조정 장부, 그것도 없으면 기준표 순서로 —
|
||||||
|
|
||||||
|
> **기준가(앵커링가) = 목표가 × (1 − 앵커링 값)**, 1원 단위 내림
|
||||||
|
>
|
||||||
|
> 예) 목표가 30,000원, 앵커링 값 3% → 기준가 29,100원 — 협력사가 이 이하를 써내면 그 가격으로 합의
|
||||||
|
>
|
||||||
|
> 이 기준가는 협력사 화면에 **표시되지 않고**, 챗봇이 합의 가능 여부를 판단하는 내부 기준선으로만 동작합니다(정보 비대칭 유지 전략).
|
||||||
|
|
||||||
|
을 계산합니다. 그리고 **이 협상에 쓴 비율과 기준가를 협상 기록에 도장 찍듯 고정(박제)** 합니다. 이후 2주 정산이 지나가서 칸의 값이 바뀌어도, 이미 만들어진 협상의 기준가는 절대 흔들리지 않습니다. 협상이 다음 라운드로 재생성될 때도 옛 값을 물려받지 않고 그 시점의 칸 값으로 새로 계산합니다.
|
||||||
|
|
||||||
|
### ③ 협력사가 가격을 써낼 때 — "가격 흔적"
|
||||||
|
|
||||||
|
협력사가 협상 채팅에서 가격을 입력할 때마다, 그 **마지막 제시가가 협상 기록에 남습니다**(`last_offered_price`).
|
||||||
|
|
||||||
|
이게 중요한 이유: **가격을 써낸** 협상과 **한 번도 안 써낸** 협상은 정책적으로 완전히 다르게 취급하기 때문입니다.
|
||||||
|
|
||||||
|
- 가격을 써냈는데 기준가 아래로 합의가 안 됨(결렬·중간 이탈 포함) = **"기준이 시장보다 세다"는 신호** = 실패로 카운트
|
||||||
|
- 가격을 한 번도 안 써내고 끝남(미참여·무응답, 담당자 부재 등) = 기준가는 화면에 안 보이므로 우리 기준과 무관 = 판단 재료에서 제외
|
||||||
|
|
||||||
|
### ④ 협상이 끝나면
|
||||||
|
|
||||||
|
따로 하는 일이 없습니다. 계약서 철(협상 기록)에 결과가 이미 다 남아 있으니까요.
|
||||||
|
|
||||||
|
이게 이번 설계(v1.2)의 특징입니다 — 별도 표본 장부를 만들지 않고, **협상 기록 자체를 나중에 채점 근거로** 씁니다. 기준가·마지막 제시가·투찰가·종료 상태가 전부 확정된 값이라, 언제 채점해도 같은 결과가 나옵니다.
|
||||||
|
|
||||||
|
### ⑤ 격주 토요일 자정 — "정산"
|
||||||
|
|
||||||
|
2주에 한 번 시스템이 깨어나 **아직 채점 안 된 종료 협상들을 전부** 꺼내 채점합니다:
|
||||||
|
|
||||||
|
| 상황 | 채점 |
|
||||||
|
|---|---|
|
||||||
|
| 기준가 이하로 합의 성사 | **성공** |
|
||||||
|
| 기준가보다 높게 합의(예외적) | **실패** |
|
||||||
|
| 가격을 써냈지만 합의 못 함 — 결렬·중간 이탈 포함 | **실패** |
|
||||||
|
| 가격을 한 번도 안 써내고 끝남 | **제외** (셈에서 뺌) |
|
||||||
|
|
||||||
|
칸별로 모아서 **유효 결과가 10건 이상인 칸만** 성공률을 내고, 3단계 규칙으로 **딱 한 계단**만 조정합니다:
|
||||||
|
|
||||||
|
| 성공률 | 판단 | 조치 |
|
||||||
|
|---|---|---|
|
||||||
|
| 60% 이상 | 잘 통하고 있다 | 올림 (유통 ±2%p / 총판 ±1.5%p / 제조 ±1%p) |
|
||||||
|
| 30% ~ 60% | 적당하다 | 유지 |
|
||||||
|
| 30% 미만 | 너무 셌다 | 내림 (같은 폭) |
|
||||||
|
|
||||||
|
값은 어떤 경우에도 1% 아래로 내려가지 않고 20% 위로 올라가지 않습니다.
|
||||||
|
|
||||||
|
정산이 끝나면:
|
||||||
|
|
||||||
|
1. 조정 장부에 한 줄 추가 — "몇 건 중 몇 건 성공, 1% → 3%, 어떤 협상들을 근거로"
|
||||||
|
2. 채점에 쓴 협상들에 **"이 조정에 사용됨" 스탬프**를 찍음 (같은 협상이 두 번 채점되는 일 방지)
|
||||||
|
3. 벽의 가격표(빠른 조회판)를 새 값으로 갱신
|
||||||
|
|
||||||
|
**10건이 안 되는 칸은 스탬프를 안 찍고 그대로 둡니다** — 그게 이월입니다. 다음 정산 때 4주치가 함께 채점되고, 그래도 부족하면 6주, 8주… 로 자연스럽게 기간이 늘어납니다.
|
||||||
|
|
||||||
|
### ⑥ 그리고 다시 ②로
|
||||||
|
|
||||||
|
다음 재협상은 조정된 값으로 시작합니다. 이 순환이 반복되면서 각 칸의 값은 "시장이 받아주는 한계선" 근처에서 자연스럽게 안정됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이 구조가 주는 안전장치
|
||||||
|
|
||||||
|
- **같은 협상이 두 번 채점될 수 없음** — 스탬프 찍힌 협상은 다음 정산에서 자동으로 빠집니다. 정산이 실수로 두 번 돌아도 결과가 같습니다.
|
||||||
|
- **정산이 한 번 빠져도 문제 없음** — 다음 정산이 4주치를 한 번에 채점하고, 그래도 조정은 한 계단만 하므로 값이 튀지 않습니다.
|
||||||
|
- **진행 중 협상은 절대 안 흔들림** — 기준가는 협상 생성 시점에 고정되므로, 정산이 값을 바꿔도 이미 시작한 협상에는 영향이 없습니다.
|
||||||
|
- **"왜 이 칸이 7%야?"에 항상 답할 수 있음** — 조정 장부에 모든 변경이 근거(어떤 협상들, 성공률)와 함께 영구 보존됩니다. 장부는 수정·삭제가 금지돼 있습니다.
|
||||||
|
- **빠른 조회판이 날아가도 무사** — 어차피 사본이라 조정 장부에서 언제든 다시 만들 수 있습니다. 조회판(Redis)이 아예 꺼져 있어도 협상은 원본 장부를 직접 읽어 계속 동작하고, 조회판에 옛 값이 남아 있더라도 매주 정산 시각에 최신 값으로 전부 다시 붙입니다.
|
||||||
|
- **회사 간 칸막이** — A사의 협상 결과는 A사의 칸에만 반영됩니다. 다른 회사의 값과 기록은 완전히 분리됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자주 나올 질문
|
||||||
|
|
||||||
|
**Q. 값이 조정되면 기준표가 바뀌는 건가요?**
|
||||||
|
아닙니다. 기준표는 출발선이고 절대 바뀌지 않습니다. 조정 장부에 이력이 한 줄 쌓여서 "현재 위치"가 바뀌는 것입니다.
|
||||||
|
|
||||||
|
**Q. 결과가 많이 쌓이면 값이 크게 움직이나요?**
|
||||||
|
아닙니다. 13건이 쌓였든 30건이 쌓였든 성공률만 계산하고, 조정은 정산일당 딱 한 계단입니다.
|
||||||
|
|
||||||
|
**Q. 거래가 거의 없는 칸은요?**
|
||||||
|
10건이 찰 때까지 정산일마다 기다립니다(2주 → 4주 → 6주…). 그동안은 시작값(1%) 근처에 머무는데, 거래가 없는 칸이니 사업 영향도 작습니다.
|
||||||
|
|
||||||
|
**Q. 사람이 수동으로 값을 바꿀 수 있나요?**
|
||||||
|
현재 정책에서는 없습니다. 값은 오직 협상 결과 데이터로만 움직이고, 모든 변경은 이력으로 추적 가능합니다.
|
||||||
91
schedules/anchoring/docs/인수인계.md
Normal file
91
schedules/anchoring/docs/인수인계.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# 앵커링 v1.2 인수인계 명세 (negodata · agent 담당자용)
|
||||||
|
|
||||||
|
> **문서 성격**: 앵커링 시스템 v1.2 도입에 따라 `negodata`·`agent` 폴더에서 적용해야 할 변경 명세.
|
||||||
|
> 앵커링 모듈은 **`schedules/anchoring` 자립 서비스**(독립 컨테이너, 자체 스케줄러)로 개발 완료 후 전달되며, 이 문서는 그 모듈을 각 폴더에 적용하는 방법을 기술한다.
|
||||||
|
> 정책 배경: `기획용.md` / 기술 규범: `개발용.md` (§9.1, §13 참조).
|
||||||
|
|
||||||
|
## 배경 한 줄
|
||||||
|
|
||||||
|
앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.rate_adjustments`(조정 이력) + Redis 캐시이며, **`schedules/anchoring` 자립 서비스**(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다.
|
||||||
|
|
||||||
|
## 전달물 (→ 각 담당자)
|
||||||
|
|
||||||
|
| 전달물 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `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` | 정적 기본 테이블 (33,334행, 불변) |
|
||||||
|
| `schedules/anchoring/schema.sql` | `anchoring.rate_adjustments` 테이블 + `negotiation.sessions` 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의) |
|
||||||
|
| `schedules/anchoring/docker-compose.yml` | anchoring 서비스 + redis 동봉 — **negodata 는 이 redis 인스턴스를 바라본다** (`REDIS_HOST` 환경변수) |
|
||||||
|
| 이 문서 | 적용 위치·변경 전후 명세 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. negodata 변경 (견적 생성 측)
|
||||||
|
|
||||||
|
### 1.1 변경 대상
|
||||||
|
|
||||||
|
`negodata/backend/services/quotation_service.py` — `_build_quotation()` 의 세션 생성 루프(현재 448~470행 부근)와 `regenerate` 경로의 상속 로직(현재 319행 부근).
|
||||||
|
|
||||||
|
### 1.2 현재 동작 (변경 전)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 재생성: 직전 라운드 값 그대로 상속 (재계산 안 함, KTC 방식)
|
||||||
|
if inherited and iid in inherited:
|
||||||
|
tp, ap = inherited[iid]
|
||||||
|
else:
|
||||||
|
tp = self._calc_target_price(...)
|
||||||
|
# 구 방식: 견적설정 고정 비율 + float 연산
|
||||||
|
ap = int(tp * (1 - anchoring)) # anchoring = quotation_settings.anchoring_value
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 변경 후 동작 (MUST)
|
||||||
|
|
||||||
|
**target_price 산정은 그대로 두고, 앵커링가 계산만 교체한다.**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 세션(상품 × 공급사)마다:
|
||||||
|
# ① 칸 해석
|
||||||
|
# company_id = items.company_id (해당 상품의 소유 회사)
|
||||||
|
# supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3)
|
||||||
|
# bracket = min(tp // 3000, 33333)
|
||||||
|
# ② rate 조회 — 전달받은 reader 모듈 사용
|
||||||
|
rate = await get_anchor_rate(company_id, supplier_type, bracket)
|
||||||
|
# 내부 동작: Redis GET → miss 시 anchoring.rate_adjustments 최신 행 → 없으면 정적 테이블(10‰)
|
||||||
|
# supplier_type ∉ {1,2,3} 이면 get_base_rate_permille(bracket) 사용 (정적 테이블 시작값)
|
||||||
|
# ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가)
|
||||||
|
ap = tp * (1000 - rate) // 1000
|
||||||
|
# ④ 세션 INSERT 에 두 컬럼 모두 박제
|
||||||
|
sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 필수 규칙
|
||||||
|
|
||||||
|
1. **재생성(다음 라운드) 상속 폐지**: `inherited` 로 앵커링가를 물려주지 않는다. 다음 라운드 세션도 **생성 시점의 칸 rate 로 재계산**한다. (target_price 상속은 기존 정책대로 유지해도 무방 — 앵커만 재계산)
|
||||||
|
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 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다.
|
||||||
|
6. **Redis 장애 내성**: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다.
|
||||||
|
|
||||||
|
### 1.5 적용 전(전환기) 동작
|
||||||
|
|
||||||
|
이 변경이 적용되기 전까지는 지금처럼 구 방식 값이 박제되어도 시스템은 안전하게 동작한다 — 협상 결과 판정은 "박제된 앵커가" 기준이므로 학습 데이터는 유효하게 쌓이고, 이 변경이 적용되는 시점부터 조정된 rate 가 실제 제안가에 반영되기 시작한다. 별도 데이터 마이그레이션은 필요 없다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. agent — **변경 없음**
|
||||||
|
|
||||||
|
앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offered_price`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 적용 순서 (권장)
|
||||||
|
|
||||||
|
```
|
||||||
|
① DB 스키마 적용 (schedules/anchoring/schema.sql — rate_adjustments + sessions 컬럼 3개)
|
||||||
|
② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작)
|
||||||
|
+ backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작)
|
||||||
|
③ negodata 적용 (앵커 산출 교체 — 이 시점부터 조정된 rate 가 실제 기준가에 반영)
|
||||||
|
```
|
||||||
|
|
||||||
|
각 단계는 독립적으로 안전하다(어느 단계까지만 적용돼도 기존 동작이 깨지지 않음). agent 는 변경 대상이 아니다. 문의는 backend 담당(민헌)에게.
|
||||||
3
schedules/anchoring/pytest.ini
Normal file
3
schedules/anchoring/pytest.ini
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
9
schedules/anchoring/requirements.txt
Normal file
9
schedules/anchoring/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# anchoring 자립 모듈 (async — negodata 이식 호환)
|
||||||
|
SQLAlchemy>=2.0
|
||||||
|
greenlet>=3.0
|
||||||
|
asyncpg>=0.29
|
||||||
|
redis>=5.0
|
||||||
|
APScheduler>=3.10
|
||||||
|
# 테스트
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-asyncio>=0.23
|
||||||
39
schedules/anchoring/schema.sql
Normal file
39
schedules/anchoring/schema.sql
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- anchoring 모듈 DDL (모듈 소유 — postgres-init 에 두지 않는다)
|
||||||
|
-- 적용: psql -h <host> -U <user> -d negosium_db -f schema.sql
|
||||||
|
-- 신규 DB 구축 순서: postgres-init/01~04 → 이 파일
|
||||||
|
-- 규범: docs/개발용.md §6. IF NOT EXISTS 라 재적용 안전.
|
||||||
|
-- 컨벤션: FK/CHECK/PG ENUM 없음, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC).
|
||||||
|
-- ============================================================
|
||||||
|
\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..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);
|
||||||
|
|
||||||
|
-- 선행요건 §13 + 소비 마킹. target_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
|
||||||
|
|
||||||
|
-- 배치 스캔 최적화: 미처리 세션만 (부분 인덱스)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
|
||||||
|
ON negotiation.sessions (qt_type, status)
|
||||||
|
WHERE anchoring_adjustment_id IS NULL AND deleted = false;
|
||||||
0
schedules/anchoring/src/anchoring/__init__.py
Normal file
0
schedules/anchoring/src/anchoring/__init__.py
Normal file
58
schedules/anchoring/src/anchoring/base_table.py
Normal file
58
schedules/anchoring/src/anchoring/base_table.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"""정적 기본 테이블 — 칸 시작값의 유일한 소스. 규범: §2.
|
||||||
|
|
||||||
|
resources/anchoring_base.json(33,334행, 불변)을 서비스 기동 시 메모리에 로드한다.
|
||||||
|
DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증 실패 시 기동 중단(§13-7).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from anchoring.constants import BRACKET_COUNT, PRICE_BRACKET_UNIT, PRICE_MAX
|
||||||
|
|
||||||
|
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
|
||||||
|
|
||||||
|
_rates: list[int] | None = None # bracket_index → 시작값(‰)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTableError(RuntimeError):
|
||||||
|
"""정적 테이블 로드/검증 실패 — 기동 중단용."""
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(rows: list) -> list[int]:
|
||||||
|
"""행 검증 후 천분율 정수 리스트로 변환. 실패 시 BaseTableError.
|
||||||
|
|
||||||
|
규약(§2.1): 33,334행 · idx 1..33334 연속 · upper_bound == min(idx*3000, 1억) · 값 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] = []
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
idx = row.get("idx")
|
||||||
|
ub = row.get("upper_bound")
|
||||||
|
av = row.get("anchoring_value")
|
||||||
|
if idx != i + 1:
|
||||||
|
raise BaseTableError(f"idx 불연속: 위치 {i} 의 idx={idx} (기대 {i + 1})")
|
||||||
|
if ub != min(idx * PRICE_BRACKET_UNIT, PRICE_MAX):
|
||||||
|
raise BaseTableError(f"upper_bound 불일치: idx={idx} upper_bound={ub}")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def load_base_table() -> None:
|
||||||
|
"""리소스 파일 로드 + 검증. 기동 시 1회 호출(멱등)."""
|
||||||
|
global _rates
|
||||||
|
if _rates 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)
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_rate_permille(bracket_index: int) -> int:
|
||||||
|
"""구간 인덱스 → 시작 앵커링 값(‰). §2.1"""
|
||||||
|
if _rates is None:
|
||||||
|
load_base_table()
|
||||||
|
return _rates[bracket_index]
|
||||||
246
schedules/anchoring/src/anchoring/batch.py
Normal file
246
schedules/anchoring/src/anchoring/batch.py
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
"""격주 조정 배치. 규범: §8.
|
||||||
|
|
||||||
|
절차: (매주, 게이트 무관) 캐시 re-SET → 격주 게이트 → 미처리 종료 재협상 세션 스캔
|
||||||
|
→ 파생 판정 → EXCLUDED/칸 불가 마킹 0 → 칸별 [조정 INSERT + 소비 마킹 한 트랜잭션,
|
||||||
|
rowcount ≠ n 이면 전체 롤백(MUST — 유니크 가드 없는 구조에서 이중 조정의 유일한 방어선)]
|
||||||
|
→ 커밋 후 Redis SET → 요약 로그(노출률 0% 면 WARN).
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from anchoring.base_table import get_base_rate_permille
|
||||||
|
from anchoring.constants import (
|
||||||
|
ANCHOR_RATE_MAX,
|
||||||
|
ANCHOR_RATE_MIN,
|
||||||
|
EVAL_WEEK_PARITY,
|
||||||
|
MARK_EXCLUDED,
|
||||||
|
QT_TYPE_RENEGO,
|
||||||
|
SAMPLE_THRESHOLD,
|
||||||
|
SAMPLEABLE_SUPPLIER_TYPES,
|
||||||
|
SESSION_STATUS_DONE,
|
||||||
|
TERMINAL_SESSION_STATUSES,
|
||||||
|
AnchoringSampleType,
|
||||||
|
)
|
||||||
|
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 set_rate
|
||||||
|
from anchoring.service import calc_bracket_index, evaluate_pending, judge_sample_type
|
||||||
|
|
||||||
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
|
_MARK_CHUNK = 1000
|
||||||
|
|
||||||
|
|
||||||
|
class MarkingConflictError(RuntimeError):
|
||||||
|
"""소비 마킹 rowcount 불일치 — 경합/오설정. 트랜잭션 전체 롤백 트리거."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_evaluation_week(now_kst: datetime) -> bool:
|
||||||
|
"""격주 게이트: ISO 주차 홀짝(기준 패리티 상수 고정). §8"""
|
||||||
|
return now_kst.isocalendar().week % 2 == EVAL_WEEK_PARITY
|
||||||
|
|
||||||
|
|
||||||
|
async def _reconcile_cache() -> int:
|
||||||
|
"""절차 0.5 — 조정 이력 보유 칸 전체의 최신 rate 를 Redis 일괄 re-SET.
|
||||||
|
|
||||||
|
stale 키는 미스가 나지 않으므로(TTL 전까지) 매주 이걸로 회복한다(§7).
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
RateAdjustment.company_id,
|
||||||
|
RateAdjustment.supplier_type,
|
||||||
|
RateAdjustment.price_bracket_index,
|
||||||
|
RateAdjustment.anchor_rate_after,
|
||||||
|
)
|
||||||
|
.distinct(
|
||||||
|
RateAdjustment.company_id,
|
||||||
|
RateAdjustment.supplier_type,
|
||||||
|
RateAdjustment.price_bracket_index,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
RateAdjustment.company_id,
|
||||||
|
RateAdjustment.supplier_type,
|
||||||
|
RateAdjustment.price_bracket_index,
|
||||||
|
RateAdjustment.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):
|
||||||
|
ok += 1
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_pending(db) -> list:
|
||||||
|
"""미처리 종료 재협상 세션 + 칸 해석 소스(supplier_type/company_id) 조인. §8 절차 1"""
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Session.session_id,
|
||||||
|
Session.status,
|
||||||
|
Session.bid_price,
|
||||||
|
Session.target_price,
|
||||||
|
Session.target_anchoring_price,
|
||||||
|
Session.last_offered_price,
|
||||||
|
Quotation.supplier_type,
|
||||||
|
Item.company_id,
|
||||||
|
)
|
||||||
|
.join(Quotation, Quotation.qt_id == Session.quotation_id, isouter=True)
|
||||||
|
.join(Item, Item.item_id == Session.item_id, isouter=True)
|
||||||
|
.where(
|
||||||
|
Session.anchoring_adjustment_id.is_(None),
|
||||||
|
Session.deleted.is_(False),
|
||||||
|
Session.qt_type == QT_TYPE_RENEGO,
|
||||||
|
Session.status.in_(TERMINAL_SESSION_STATUSES),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_sessions(db, session_ids: list, adjustment_id: int) -> int:
|
||||||
|
"""소비/제외 마킹. `IS NULL` 조건으로 이중 마킹 차단. 반환: 실제 마킹 행 수."""
|
||||||
|
marked = 0
|
||||||
|
for i in range(0, len(session_ids), _MARK_CHUNK):
|
||||||
|
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)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
)
|
||||||
|
marked += res.rowcount
|
||||||
|
return marked
|
||||||
|
|
||||||
|
|
||||||
|
async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples: list) -> dict | None:
|
||||||
|
"""칸 1개 평가 — 조정 INSERT + 소비 마킹을 같은 세션 한 트랜잭션으로(§8 MUST).
|
||||||
|
|
||||||
|
samples: [(session_id, sample_type_code)] — 유효 표본만, n ≥ 10 보장 후 호출.
|
||||||
|
반환: 요약용 dict / 마킹 경합 시 예외(트랜잭션 롤백).
|
||||||
|
"""
|
||||||
|
session_ids = [sid for sid, _ in 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 보장)
|
||||||
|
return None
|
||||||
|
|
||||||
|
adjustment = RateAdjustment(
|
||||||
|
company_id=company_id,
|
||||||
|
supplier_type=supplier_type,
|
||||||
|
price_bracket_index=bracket,
|
||||||
|
nego_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],
|
||||||
|
)
|
||||||
|
db.add(adjustment)
|
||||||
|
await db.flush() # adjustment.id 확보
|
||||||
|
|
||||||
|
marked = await _mark_sessions(db, session_ids, adjustment.id)
|
||||||
|
if marked != len(session_ids):
|
||||||
|
# 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백
|
||||||
|
raise MarkingConflictError(
|
||||||
|
f"cell=({company_id},{supplier_type},{bracket}) 마킹 {marked}/{len(session_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복)
|
||||||
|
await set_rate(company_id, supplier_type, bracket, rate_after)
|
||||||
|
return {"before": rate_before, "after": rate_after}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_evaluation_batch(force: bool = False) -> dict:
|
||||||
|
"""배치 1회. force=True 면 격주 게이트만 무시(정책 파라미터는 불변)."""
|
||||||
|
now = datetime.now(KST)
|
||||||
|
|
||||||
|
# 절차 0.5 — 캐시 정합(매주, 게이트 무관)
|
||||||
|
reconciled = await _reconcile_cache()
|
||||||
|
LOG.info(f"[batch] 캐시 re-SET {reconciled}칸")
|
||||||
|
|
||||||
|
if not force and not is_evaluation_week(now):
|
||||||
|
LOG.info(f"[batch] 격주 게이트 미충족(ISO 주차 {now.isocalendar().week}) — 평가 스킵")
|
||||||
|
return {"status": "skipped", "reason": "week_parity", "cache_reconciled": reconciled}
|
||||||
|
|
||||||
|
# 절차 1~2 — 스캔 + 파생 판정
|
||||||
|
async with session_scope() as db:
|
||||||
|
rows = await _scan_pending(db)
|
||||||
|
|
||||||
|
excluded_ids: list = []
|
||||||
|
cells: dict[tuple, list] = defaultdict(list)
|
||||||
|
priced = 0
|
||||||
|
for r in rows:
|
||||||
|
if r.last_offered_price is not None:
|
||||||
|
priced += 1
|
||||||
|
if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None:
|
||||||
|
excluded_ids.append(r.session_id) # 칸 구성 불가
|
||||||
|
continue
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if sample_type == AnchoringSampleType.EXCLUDED.value:
|
||||||
|
excluded_ids.append(r.session_id)
|
||||||
|
continue
|
||||||
|
bracket = calc_bracket_index(r.target_price)
|
||||||
|
cells[(r.company_id, r.supplier_type, bracket)].append((r.session_id, sample_type))
|
||||||
|
|
||||||
|
# 가격 제시율 — backend 의 last_offered_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5)
|
||||||
|
if rows and priced == 0:
|
||||||
|
LOG.warning(f"[batch] 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offered_price 전무) "
|
||||||
|
f"— backend 가격 입력 기록 배선 점검 필요")
|
||||||
|
|
||||||
|
# 절차 2 — 제외 확정 마킹(재스캔 방지)
|
||||||
|
if excluded_ids:
|
||||||
|
async with session_scope() as db:
|
||||||
|
await _mark_sessions(db, excluded_ids, MARK_EXCLUDED)
|
||||||
|
|
||||||
|
# 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음)
|
||||||
|
evaluated = up = hold = down = clamped = failed = 0
|
||||||
|
carryover = 0
|
||||||
|
for (company_id, stype, bracket), samples in cells.items():
|
||||||
|
if len(samples) < SAMPLE_THRESHOLD:
|
||||||
|
carryover += 1 # 마킹하지 않음 = 이월(§4.4)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = await _evaluate_cell(company_id, stype, bracket, samples)
|
||||||
|
except Exception as ex:
|
||||||
|
failed += 1
|
||||||
|
LOG.error(f"[batch] 칸 평가 실패 cell=({company_id},{stype},{bracket}): {ex}", exc_info=True)
|
||||||
|
continue
|
||||||
|
if result is None:
|
||||||
|
carryover += 1
|
||||||
|
continue
|
||||||
|
evaluated += 1
|
||||||
|
if result["after"] > result["before"]:
|
||||||
|
up += 1
|
||||||
|
elif result["after"] < result["before"]:
|
||||||
|
down += 1
|
||||||
|
else:
|
||||||
|
hold += 1
|
||||||
|
if result["after"] in (ANCHOR_RATE_MIN, ANCHOR_RATE_MAX):
|
||||||
|
clamped += 1
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"status": "done" if failed == 0 else "partial",
|
||||||
|
"scanned": len(rows),
|
||||||
|
"priced_rate": (priced / len(rows)) if rows else None,
|
||||||
|
"excluded_marked": len(excluded_ids),
|
||||||
|
"evaluated_cells": evaluated,
|
||||||
|
"up": up, "hold": hold, "down": down, "clamped": clamped,
|
||||||
|
"carryover_cells": carryover,
|
||||||
|
"failed_cells": failed,
|
||||||
|
"cache_reconciled": reconciled,
|
||||||
|
}
|
||||||
|
LOG.info(f"[batch] 종료 {summary}")
|
||||||
|
return summary
|
||||||
66
schedules/anchoring/src/anchoring/config.py
Normal file
66
schedules/anchoring/src/anchoring/config.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""설정 — config.toml + env 오버라이드(env > toml > 기본값).
|
||||||
|
|
||||||
|
자립 모듈: backend config 체계를 쓰지 않는다. 시크릿은 config.toml(.gitignore) 또는 env 로.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.toml"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DBConfig:
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 5432
|
||||||
|
user: str = "postgres"
|
||||||
|
password: str = "postgres"
|
||||||
|
name: str = "negosium_db"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RedisConfig:
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 6379
|
||||||
|
db: int = 0
|
||||||
|
password: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
db: DBConfig = field(default_factory=DBConfig)
|
||||||
|
redis: RedisConfig = field(default_factory=RedisConfig)
|
||||||
|
log_level: str = "info"
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, current, cast=str):
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
return cast(raw) if raw is not None else current
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Config:
|
||||||
|
cfg = Config()
|
||||||
|
if _CONFIG_PATH.exists():
|
||||||
|
data = tomllib.loads(_CONFIG_PATH.read_text())
|
||||||
|
db = data.get("db", {})
|
||||||
|
rd = data.get("redis", {})
|
||||||
|
cfg.db = DBConfig(**{**cfg.db.__dict__, **db})
|
||||||
|
cfg.redis = RedisConfig(**{**cfg.redis.__dict__, **rd})
|
||||||
|
cfg.log_level = data.get("log_level", cfg.log_level)
|
||||||
|
|
||||||
|
cfg.db.host = _env("DB_HOST", cfg.db.host)
|
||||||
|
cfg.db.port = _env("DB_PORT", cfg.db.port, int)
|
||||||
|
cfg.db.user = _env("DB_USER", cfg.db.user)
|
||||||
|
cfg.db.password = _env("DB_PASSWORD", cfg.db.password)
|
||||||
|
cfg.db.name = _env("DB_NAME", cfg.db.name)
|
||||||
|
cfg.redis.host = _env("REDIS_HOST", cfg.redis.host)
|
||||||
|
cfg.redis.port = _env("REDIS_PORT", cfg.redis.port, int)
|
||||||
|
cfg.redis.db = _env("REDIS_DB", cfg.redis.db, int)
|
||||||
|
cfg.redis.password = _env("REDIS_PASSWORD", cfg.redis.password)
|
||||||
|
cfg.log_level = _env("LOG_LEVEL", cfg.log_level)
|
||||||
|
return cfg
|
||||||
75
schedules/anchoring/src/anchoring/constants.py
Normal file
75
schedules/anchoring/src/anchoring/constants.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""앵커링 도메인 상수 + 코드값(enum). 규범: docs/개발용.md §3.
|
||||||
|
|
||||||
|
backend 를 import 하지 않고 자체 보유한다(자립 모듈). 코드값은 프로젝트 컨벤션
|
||||||
|
(SMALLINT 1-based + 앱 enum 매핑)을 따르며 quotations.supplier_type 과 동일 코드다.
|
||||||
|
상수 변경은 정책 재확정 사안 — 코드에서 임의 조정 금지(§12).
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
# ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
|
||||||
|
ANCHOR_RATE_MIN = 10 # 하한 1%
|
||||||
|
ANCHOR_RATE_MAX = 200 # 상한 20%
|
||||||
|
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§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칸)
|
||||||
|
BRACKET_COUNT = 33_334
|
||||||
|
|
||||||
|
# ── 배치 ──────────────────────────────────────────────────
|
||||||
|
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
|
||||||
|
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
|
||||||
|
MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값 (BIGSERIAL 은 1부터라 충돌 없음)
|
||||||
|
|
||||||
|
# ── Redis 캐시 (§7) ──────────────────────────────────────
|
||||||
|
CACHE_TTL_SECONDS = 7 * 24 * 3600 # stale 잔존 방지 보조(주 방어선은 주간 re-SET)
|
||||||
|
REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
|
||||||
|
|
||||||
|
|
||||||
|
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 # 가격 흔적 없음(미참여·무가격 이탈) / 앵커 박제 없음 / 유형 미지정
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLEABLE_SUPPLIER_TYPES = (
|
||||||
|
SupplierType.DISTRIBUTION.value,
|
||||||
|
SupplierType.MANUFACTURE.value,
|
||||||
|
SupplierType.SOLE_AGENCY.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 협상 세션 코드값(backend SessionStatus/QtType 와 동일 매핑, 읽기용으로만 자체 보유)
|
||||||
|
SESSION_STATUS_DONE = 3 # 협상완료
|
||||||
|
SESSION_STATUS_NOT_PARTICIPATED = 4 # 미참여(마감·일괄마감)
|
||||||
|
SESSION_STATUS_REJECTED = 5 # 거부
|
||||||
|
TERMINAL_SESSION_STATUSES = (
|
||||||
|
SESSION_STATUS_DONE,
|
||||||
|
SESSION_STATUS_NOT_PARTICIPATED,
|
||||||
|
SESSION_STATUS_REJECTED,
|
||||||
|
)
|
||||||
|
QT_TYPE_RENEGO = 1 # 재협상(1:1) — 표본 대상
|
||||||
41
schedules/anchoring/src/anchoring/db.py
Normal file
41
schedules/anchoring/src/anchoring/db.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"""async SQLAlchemy 엔진/세션 (asyncpg). 자립: backend DB 매니저 미사용.
|
||||||
|
|
||||||
|
조정 INSERT + 소비 마킹은 반드시 같은 세션(session_scope 한 블록)에서 실행한다
|
||||||
|
— 한 트랜잭션 원자성이 이중 조정 방어선(§8).
|
||||||
|
"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from anchoring.config import Config
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_session_factory: async_sessionmaker | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_engine(cfg: Config) -> None:
|
||||||
|
global _engine, _session_factory
|
||||||
|
if _engine is not None:
|
||||||
|
return
|
||||||
|
_engine = create_async_engine(cfg.db.url, pool_size=5, max_overflow=5, pool_pre_ping=True)
|
||||||
|
_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_engine() -> None:
|
||||||
|
global _engine, _session_factory
|
||||||
|
if _engine is not None:
|
||||||
|
await _engine.dispose()
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session_scope():
|
||||||
|
"""정상 종료 시 commit, 예외 시 rollback."""
|
||||||
|
async with _session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
14
schedules/anchoring/src/anchoring/log.py
Normal file
14
schedules/anchoring/src/anchoring/log.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"""모듈 로거 — 표준 logging 얇은 래퍼(자립: backend logger 미사용)."""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
LOG = logging.getLogger("anchoring")
|
||||||
|
|
||||||
|
|
||||||
|
def configure(level: str = "info") -> None:
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||||
|
LOG.handlers.clear()
|
||||||
|
LOG.addHandler(handler)
|
||||||
|
LOG.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||||
|
LOG.propagate = False
|
||||||
52
schedules/anchoring/src/anchoring/main.py
Normal file
52
schedules/anchoring/src/anchoring/main.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""엔트리포인트.
|
||||||
|
|
||||||
|
기본: 스케줄러 상주(컨테이너 메인).
|
||||||
|
PYTHONPATH=src python -m anchoring.main
|
||||||
|
수동 1회(격주 게이트 무시 — 미스파이어 캐치업/운영 점검 런북):
|
||||||
|
PYTHONPATH=src python -m anchoring.main --once
|
||||||
|
|
||||||
|
기동 시 정적 테이블 검증 실패 → 예외로 즉시 중단(§13-7 MUST).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
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.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:
|
||||||
|
cfg = load_config()
|
||||||
|
configure(cfg.log_level)
|
||||||
|
|
||||||
|
load_base_table() # 검증 실패 시 BaseTableError → 기동 중단
|
||||||
|
LOG.info("[main] 정적 기본 테이블 로드·검증 완료 (33,334칸)")
|
||||||
|
init_engine(cfg)
|
||||||
|
init_redis(cfg.redis)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if once:
|
||||||
|
LOG.info("[main] 수동 1회 실행(--once, 격주 게이트 무시)")
|
||||||
|
result = await run_evaluation_batch(force=True)
|
||||||
|
LOG.info(f"[main] 결과: {result}")
|
||||||
|
return
|
||||||
|
|
||||||
|
scheduler = build_scheduler()
|
||||||
|
scheduler.start()
|
||||||
|
LOG.info("[main] 스케줄러 상주 시작")
|
||||||
|
await asyncio.Event().wait() # 컨테이너 메인 — SIGTERM 까지 대기
|
||||||
|
finally:
|
||||||
|
await close_redis()
|
||||||
|
await dispose_engine()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
asyncio.run(_run(once="--once" in sys.argv))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
64
schedules/anchoring/src/anchoring/models.py
Normal file
64
schedules/anchoring/src/anchoring/models.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""ORM 모델 — 자립(backend models 미사용).
|
||||||
|
|
||||||
|
- 소유(쓰기): anchoring.rate_adjustments (append-only — UPDATE/DELETE 금지 §5)
|
||||||
|
- sessions 는 anchoring_adjustment_id 마킹만 쓰기 가능(그 외 컬럼 수정 금지 §12).
|
||||||
|
quotations/items 는 읽기 전용 경량 매핑(집계에 필요한 컬럼만).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
BASE = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class RateAdjustment(BASE):
|
||||||
|
__tablename__ = "rate_adjustments"
|
||||||
|
__table_args__ = {"schema": "anchoring"}
|
||||||
|
|
||||||
|
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..33333
|
||||||
|
nego_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 문자열 배열(창 박제)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 읽기 전용/마킹 매핑(집계에 필요한 컬럼만) ─────────────────
|
||||||
|
class Session(BASE):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
__table_args__ = {"schema": "negotiation"}
|
||||||
|
|
||||||
|
session_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
quotation_id = Column(UUID(as_uuid=True), nullable=False)
|
||||||
|
item_id = Column(UUID(as_uuid=True), nullable=False)
|
||||||
|
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) # 소비 마킹(모듈이 쓰는 유일 컬럼)
|
||||||
|
status = Column(SmallInteger, nullable=False) # 3=DONE 4=NOT_PARTICIPATED 5=REJECTED
|
||||||
|
bid_price = Column(BigInteger, nullable=True)
|
||||||
|
deleted = Column(Boolean, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Quotation(BASE):
|
||||||
|
__tablename__ = "quotations"
|
||||||
|
__table_args__ = {"schema": "quotation"}
|
||||||
|
|
||||||
|
qt_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
supplier_type = Column(SmallInteger, nullable=True) # NULL 이면 칸 구성 불가 → 제외
|
||||||
|
deleted = Column(Boolean, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Item(BASE):
|
||||||
|
__tablename__ = "items"
|
||||||
|
__table_args__ = {"schema": "partner"}
|
||||||
|
|
||||||
|
item_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
company_id = Column(UUID(as_uuid=True), nullable=True) # 테넌트(갑) — NULL 이면 칸 구성 불가
|
||||||
|
deleted = Column(Boolean, nullable=False)
|
||||||
42
schedules/anchoring/src/anchoring/reader.py
Normal file
42
schedules/anchoring/src/anchoring/reader.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""현재 앵커링 값 조회(읽기 경로). 규범: §4.5, §7, §9.1.
|
||||||
|
|
||||||
|
negodata 이식 대상 — 견적/세션 생성 시 이 함수로 칸 rate 를 얻어
|
||||||
|
anchor_price = target_price * (1000 - rate) // 1000 를 정수 연산으로 계산·박제한다.
|
||||||
|
|
||||||
|
순서: Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 시작값 → Redis SET(best effort).
|
||||||
|
supplier_type ∉ {1,2,3} 인 경우 호출하지 말고 get_base_rate_permille(bracket) 을 직접 쓴다(§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
|
||||||
|
|
||||||
|
|
||||||
|
async def get_latest_adjusted_rate(db: AsyncSession, company_id, supplier_type: int, bracket_index: int) -> int | None:
|
||||||
|
"""칸의 최신 조정 행 rate_after. 이력 없으면 None."""
|
||||||
|
stmt = (
|
||||||
|
select(RateAdjustment.anchor_rate_after)
|
||||||
|
.where(
|
||||||
|
RateAdjustment.company_id == company_id,
|
||||||
|
RateAdjustment.supplier_type == supplier_type,
|
||||||
|
RateAdjustment.price_bracket_index == bracket_index,
|
||||||
|
)
|
||||||
|
.order_by(RateAdjustment.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:
|
||||||
|
"""칸의 현재 앵커링 값(‰). Redis → 조정 이력 → 정적 테이블 → SET."""
|
||||||
|
cached = await get_rate(company_id, supplier_type, bracket_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)
|
||||||
|
return rate
|
||||||
61
schedules/anchoring/src/anchoring/redis_client.py
Normal file
61
schedules/anchoring/src/anchoring/redis_client.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"""Redis 캐시 클라이언트. 규범: §7.
|
||||||
|
|
||||||
|
- 키: anchor:{company_id}:{supplier_type}:{bracket_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 CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT
|
||||||
|
from anchoring.log import LOG
|
||||||
|
|
||||||
|
_client: aioredis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_redis(cfg: RedisConfig) -> None:
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
return
|
||||||
|
_client = aioredis.Redis(
|
||||||
|
host=cfg.host, port=cfg.port, db=cfg.db,
|
||||||
|
password=cfg.password or None,
|
||||||
|
socket_timeout=REDIS_SOCKET_TIMEOUT,
|
||||||
|
socket_connect_timeout=REDIS_SOCKET_TIMEOUT,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis() -> None:
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
await _client.aclose()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_key(company_id, supplier_type: int, bracket_index: int) -> str:
|
||||||
|
return f"anchor:{company_id}:{supplier_type}:{bracket_index}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_rate(company_id, supplier_type: int, bracket_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))
|
||||||
|
return int(raw) if raw is not None else None
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.warning(f"[redis] GET 실패(DB 폴백) key={anchor_key(company_id, supplier_type, bracket_index)}: {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int) -> bool:
|
||||||
|
"""캐시 적재(best effort, TTL 7일). 실패해도 예외를 밖으로 던지지 않는다."""
|
||||||
|
if _client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await _client.set(anchor_key(company_id, supplier_type, bracket_index), str(rate), ex=CACHE_TTL_SECONDS)
|
||||||
|
return True
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.warning(f"[redis] SET 실패(주간 re-SET 이 회복) key={anchor_key(company_id, supplier_type, bracket_index)}: {ex}")
|
||||||
|
return False
|
||||||
33336
schedules/anchoring/src/anchoring/resources/anchoring_base.json
Normal file
33336
schedules/anchoring/src/anchoring/resources/anchoring_base.json
Normal file
File diff suppressed because it is too large
Load Diff
34
schedules/anchoring/src/anchoring/scheduler.py
Normal file
34
schedules/anchoring/src/anchoring/scheduler.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"""APScheduler — '언제'(when) 담당. 규범: §8.
|
||||||
|
|
||||||
|
매주 토 00:00 KST 트리거(격주 게이트는 잡 내부 is_evaluation_week). 자립 단일 컨테이너가
|
||||||
|
곧 스케줄러라 중복 실행이 원천 차단된다(추가로 max_instances=1). 잡 예외는 잡 안에서만
|
||||||
|
처리해 스케줄러는 죽지 않는다.
|
||||||
|
"""
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
from anchoring.batch import run_evaluation_batch
|
||||||
|
from anchoring.log import LOG
|
||||||
|
|
||||||
|
TIMEZONE = "Asia/Seoul"
|
||||||
|
|
||||||
|
|
||||||
|
async def _job() -> None:
|
||||||
|
try:
|
||||||
|
await run_evaluation_batch(force=False)
|
||||||
|
except Exception as ex: # 어떤 경우에도 스케줄러는 살아있어야 한다
|
||||||
|
LOG.error(f"[scheduler] run_evaluation_batch 예외(무시하고 다음 트리거 대기): {ex}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_scheduler() -> AsyncIOScheduler:
|
||||||
|
scheduler = AsyncIOScheduler(timezone=TIMEZONE)
|
||||||
|
scheduler.add_job(
|
||||||
|
_job,
|
||||||
|
CronTrigger(day_of_week="sat", hour=0, minute=0, timezone=TIMEZONE),
|
||||||
|
id="anchoring_biweekly_evaluation",
|
||||||
|
coalesce=True, # 밀린 실행이 쌓여도 1번만
|
||||||
|
misfire_grace_time=3600, # 늦게 깨어나도 1시간 내면 실행 (초과 시 --once 런북)
|
||||||
|
max_instances=1,
|
||||||
|
)
|
||||||
|
LOG.info(f"[scheduler] 등록 — 매주 토 00:00 {TIMEZONE} (격주 게이트는 잡 내부)")
|
||||||
|
return scheduler
|
||||||
77
schedules/anchoring/src/anchoring/service.py
Normal file
77
schedules/anchoring/src/anchoring/service.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""순수 계산 함수 — DB/Redis 접근 없음. 규범: §4, §10.
|
||||||
|
|
||||||
|
모든 산술은 정수(천분율 ‰). float 금지(§12) — 성공률 비교도 정수 비교로 수행한다.
|
||||||
|
"""
|
||||||
|
from anchoring.base_table import get_base_rate_permille
|
||||||
|
from anchoring.constants import (
|
||||||
|
ANCHOR_RATE_MAX,
|
||||||
|
ANCHOR_RATE_MIN,
|
||||||
|
BRACKET_INDEX_MAX,
|
||||||
|
DELTA_PERMILLE,
|
||||||
|
PRICE_BRACKET_UNIT,
|
||||||
|
SAMPLE_THRESHOLD,
|
||||||
|
AnchoringSampleType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
139
schedules/anchoring/tests/conftest.py
Normal file
139
schedules/anchoring/tests/conftest.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""통합 테스트 픽스처 — 실제 Postgres 필요(로컬 dev DB), 없으면 자동 스킵.
|
||||||
|
|
||||||
|
컨벤션(backend 와 동일): 전용 행을 시드하고 테스트 후 직접 정리한다.
|
||||||
|
Redis 는 초기화하지 않는다 — 클라이언트 None → get None(DB 폴백)/set no-op 로 무Redis 실행.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from anchoring import db as adb
|
||||||
|
from anchoring.config import load_config
|
||||||
|
|
||||||
|
CFG = load_config()
|
||||||
|
_SCHEMA_SQL = Path(__file__).resolve().parents[1] / "schema.sql"
|
||||||
|
|
||||||
|
|
||||||
|
def _db_available() -> bool:
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
conn = await asyncpg.connect(
|
||||||
|
host=CFG.db.host, port=CFG.db.port, user=CFG.db.user,
|
||||||
|
password=CFG.db.password, database=CFG.db.name, timeout=2,
|
||||||
|
)
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(_check())
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
DB_OK = _db_available()
|
||||||
|
requires_db = pytest.mark.skipif(not DB_OK, reason="로컬 Postgres(negosium_db) 미가용 — 통합 테스트 스킵")
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_statements() -> list[str]:
|
||||||
|
"""schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리."""
|
||||||
|
lines = [
|
||||||
|
line for line in _SCHEMA_SQL.read_text().splitlines()
|
||||||
|
if not line.startswith("\\") and not line.strip().startswith("--")
|
||||||
|
]
|
||||||
|
return [s.strip() for s in "\n".join(lines).split(";") if s.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_ready():
|
||||||
|
"""테스트별 엔진(이벤트 루프 수명 일치) + 스키마 멱등 적용."""
|
||||||
|
adb.init_engine(CFG)
|
||||||
|
async with adb.session_scope() as s:
|
||||||
|
for stmt in _schema_statements():
|
||||||
|
await s.execute(text(stmt))
|
||||||
|
yield
|
||||||
|
await adb.dispose_engine()
|
||||||
|
|
||||||
|
|
||||||
|
class Seeder:
|
||||||
|
"""전용 시드 생성 + 정리. 한 인스턴스 = 한 회사(테넌트)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.company_id = uuid.uuid4()
|
||||||
|
self.user_id = uuid.uuid4()
|
||||||
|
self.item_id = uuid.uuid4()
|
||||||
|
self.quotation_ids: list = []
|
||||||
|
self._item_created = False
|
||||||
|
|
||||||
|
async def _ensure_item(self, db):
|
||||||
|
if self._item_created:
|
||||||
|
return
|
||||||
|
await db.execute(text(
|
||||||
|
"INSERT INTO partner.items (item_id, company_id, user_id, name) "
|
||||||
|
"VALUES (:iid, :cid, :uid, 'anchoring-it-test')"
|
||||||
|
), {"iid": self.item_id, "cid": self.company_id, "uid": self.user_id})
|
||||||
|
self._item_created = True
|
||||||
|
|
||||||
|
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,
|
||||||
|
):
|
||||||
|
"""종료 재협상 세션 1건 시드. 반환: session_id.
|
||||||
|
|
||||||
|
last_offered_price 기본값은 bid_price(가격 흔적 = 투찰가). None 을 명시하면 가격 흔적 없는 세션.
|
||||||
|
"""
|
||||||
|
if last_offered_price is ...:
|
||||||
|
last_offered_price = bid_price
|
||||||
|
await self._ensure_item(db)
|
||||||
|
qt_id = uuid.uuid4()
|
||||||
|
self.quotation_ids.append(qt_id)
|
||||||
|
await db.execute(text(
|
||||||
|
"INSERT INTO quotation.quotations "
|
||||||
|
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, "
|
||||||
|
" start_time, end_time, supplier_type) "
|
||||||
|
"VALUES (:qid, :uid, :sid, :vid, 'anchoring-it-test', :num, :qtype, 1, 3, now(), now(), :stype)"
|
||||||
|
), {
|
||||||
|
"qid": qt_id, "uid": self.user_id, "sid": uuid.uuid4(), "vid": uuid.uuid4(),
|
||||||
|
"num": f"AT{uuid.uuid4().hex[:12]}", "qtype": qt_type, "stype": supplier_type,
|
||||||
|
})
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
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, "
|
||||||
|
" status, bid_price, end_time) "
|
||||||
|
"VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :rate, :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,
|
||||||
|
})
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
async def cleanup(self, db):
|
||||||
|
await db.execute(text(
|
||||||
|
"DELETE FROM anchoring.rate_adjustments WHERE company_id = :cid"
|
||||||
|
), {"cid": self.company_id})
|
||||||
|
if self.quotation_ids:
|
||||||
|
await db.execute(
|
||||||
|
text("DELETE FROM negotiation.sessions WHERE quotation_id = ANY(:qids)"),
|
||||||
|
{"qids": self.quotation_ids},
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
text("DELETE FROM quotation.quotations WHERE qt_id = ANY(:qids)"),
|
||||||
|
{"qids": self.quotation_ids},
|
||||||
|
)
|
||||||
|
await db.execute(text("DELETE FROM partner.items WHERE item_id = :iid"), {"iid": self.item_id})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def seeder(db_ready):
|
||||||
|
s = Seeder()
|
||||||
|
yield s
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
await s.cleanup(db)
|
||||||
154
schedules/anchoring/tests/test_batch.py
Normal file
154
schedules/anchoring/tests/test_batch.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"""배치 통합 테스트 (스펙 §11.5 — 실제 Postgres, Redis 없음(무Redis 폴백 경로)).
|
||||||
|
|
||||||
|
실행: cd schedules/anchoring && PYTHONPATH=src .venv/bin/python -m pytest tests/test_batch.py -q
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
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 conftest import requires_db
|
||||||
|
|
||||||
|
pytestmark = requires_db
|
||||||
|
|
||||||
|
# 시드 기본값: target 30,000 / rate 10‰ / anchor 29,700 → bracket = 30000//3000 = 10
|
||||||
|
BRACKET = 10
|
||||||
|
SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
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))
|
||||||
|
return dict((await db.execute(stmt)).all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_mixed(db, seeder, success: int, fail: int, **kw):
|
||||||
|
ids = []
|
||||||
|
for _ in range(success):
|
||||||
|
ids.append(await seeder.seed_session(db, bid_price=SUCCESS_BID, **kw))
|
||||||
|
for _ in range(fail):
|
||||||
|
ids.append(await seeder.seed_session(db, bid_price=FAIL_BID, **kw))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
# ── §11.5: 13건 전량 평가 + 멱등 (실패 3종 혼합) ──────────
|
||||||
|
async def test_full_cycle_and_idempotency(seeder):
|
||||||
|
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))
|
||||||
|
# 가격 쓰고 이탈 → 견적 마감 시 일괄 NOT_PARTICIPATED = 실패 (중간 이탈 시나리오)
|
||||||
|
ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offered_price=FAIL_BID))
|
||||||
|
# 합계 13건, 성공 8 → r≈0.615 → +20
|
||||||
|
|
||||||
|
await run_evaluation_batch(force=True)
|
||||||
|
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
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)
|
||||||
|
marks = await _marks(db, ids)
|
||||||
|
assert all(v == adj.id for v in marks.values()) # 13건 모두 소비 마킹
|
||||||
|
|
||||||
|
# 재실행 — 마킹 멱등: 우리 칸 조정은 그대로 1건
|
||||||
|
await run_evaluation_batch(force=True)
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
assert len(await _adjustments(db, seeder)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── §11.5: 이월(7건 스킵 → 누적 13건 단일 평가) ──────────
|
||||||
|
async def test_carryover(seeder):
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
first = await _seed_mixed(db, seeder, success=5, fail=2) # 7건 < 10
|
||||||
|
|
||||||
|
await run_evaluation_batch(force=True)
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
assert await _adjustments(db, seeder) == []
|
||||||
|
marks = await _marks(db, first)
|
||||||
|
assert all(v is None for v in marks.values()) # 마킹 없음 = 이월
|
||||||
|
|
||||||
|
second = await _seed_mixed(db, seeder, success=3, fail=3) # 누적 13건 (8S/5F)
|
||||||
|
|
||||||
|
await run_evaluation_batch(force=True)
|
||||||
|
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
|
||||||
|
marks = await _marks(db, first + second)
|
||||||
|
assert all(v == adjustments[0].id for v in marks.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ── §11.5: 회사 격리 + 현재값 조회(무Redis DB 폴백) + δ 유형 차원 ──
|
||||||
|
async def test_company_isolation_and_reader(seeder):
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
await _seed_mixed(db, seeder, success=10, fail=0, supplier_type=1) # 유통 → +20
|
||||||
|
await _seed_mixed(db, seeder, success=10, fail=0, supplier_type=2) # 제조 → +10 (δ 스왑 가드)
|
||||||
|
|
||||||
|
await run_evaluation_batch(force=True)
|
||||||
|
|
||||||
|
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}
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── §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)]
|
||||||
|
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)] # 칸 구성 불가
|
||||||
|
|
||||||
|
await run_evaluation_batch(force=True)
|
||||||
|
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
assert await _adjustments(db, seeder) == [] # 유효 5 < 10 → 평가 없음
|
||||||
|
marks = await _marks(db, excluded + untyped)
|
||||||
|
assert all(v == 0 for v in marks.values()) # 제외 확정 마킹(재스캔 방지)
|
||||||
|
marks = await _marks(db, valid)
|
||||||
|
assert all(v is None for v in marks.values()) # 유효 표본은 이월
|
||||||
|
|
||||||
|
|
||||||
|
# ── 개정 1: 마킹 rowcount ≠ n → 조정 INSERT 포함 전체 롤백 ──
|
||||||
|
async def test_marking_conflict_rolls_back(seeder):
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
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"
|
||||||
|
), {"sid": ids[0]})
|
||||||
|
|
||||||
|
samples = [(sid, 1) for sid in ids] # 10건 전부 BID_SUCCESS 로 평가 시도
|
||||||
|
try:
|
||||||
|
await _evaluate_cell(seeder.company_id, 1, BRACKET, samples)
|
||||||
|
raised = False
|
||||||
|
except MarkingConflictError:
|
||||||
|
raised = True
|
||||||
|
assert raised
|
||||||
|
|
||||||
|
async with adb.session_scope() as db:
|
||||||
|
assert await _adjustments(db, seeder) == [] # 롤백 — 이중 조정 없음
|
||||||
|
marks = await _marks(db, ids[1:])
|
||||||
|
assert all(v is None for v in marks.values()) # 나머지 9건 마킹도 롤백
|
||||||
118
schedules/anchoring/tests/test_core.py
Normal file
118
schedules/anchoring/tests/test_core.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""순수 로직 골든 테스트 (스펙 §11.1~11.4, 외부 의존성 없음).
|
||||||
|
|
||||||
|
실행: cd schedules/anchoring && PYTHONPATH=src python -m pytest tests/test_core.py -q
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from anchoring.base_table import BaseTableError, _validate, get_base_rate_permille, load_base_table
|
||||||
|
from anchoring.constants import BRACKET_COUNT, AnchoringSampleType
|
||||||
|
from anchoring.service import (
|
||||||
|
calc_anchor_price,
|
||||||
|
calc_bracket_index,
|
||||||
|
evaluate_pending,
|
||||||
|
get_current_rate,
|
||||||
|
judge_sample_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
S = AnchoringSampleType.BID_SUCCESS.value
|
||||||
|
F = AnchoringSampleType.BID_FAIL.value
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── §11.2 구간 인덱스 (상한 클램프 포함) ──────────────────
|
||||||
|
def test_bracket_index():
|
||||||
|
assert calc_bracket_index(0) == 0
|
||||||
|
assert calc_bracket_index(2_999) == 0
|
||||||
|
assert calc_bracket_index(3_000) == 1 # 경계는 상위 구간
|
||||||
|
assert calc_bracket_index(99_999_000) == 33_333 # 마지막 구간 진입
|
||||||
|
assert calc_bracket_index(100_000_000) == 33_333
|
||||||
|
assert calc_bracket_index(150_000_000) == 33_333 # 1억 초과 → 마지막 인덱스 클램프
|
||||||
|
|
||||||
|
|
||||||
|
# ── §2 정적 테이블 로드·검증 ─────────────────────────────
|
||||||
|
def test_base_table_load_and_values():
|
||||||
|
load_base_table()
|
||||||
|
assert get_base_rate_permille(0) == 10
|
||||||
|
assert get_base_rate_permille(33_333) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(n: int = BRACKET_COUNT):
|
||||||
|
return [
|
||||||
|
{"idx": k, "upper_bound": min(k * 3000, 100_000_000), "anchoring_value": 0.01}
|
||||||
|
for k in range(1, n + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_table_validate_ok():
|
||||||
|
rates = _validate(_rows())
|
||||||
|
assert len(rates) == BRACKET_COUNT and set(rates) == {10}
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_table_validate_rejects_bad():
|
||||||
|
with pytest.raises(BaseTableError): # 행 수 부족
|
||||||
|
_validate(_rows()[:-1])
|
||||||
|
rows = _rows()
|
||||||
|
rows[5]["idx"] = 999 # idx 불연속
|
||||||
|
with pytest.raises(BaseTableError):
|
||||||
|
_validate(rows)
|
||||||
|
rows = _rows()
|
||||||
|
rows[-1]["upper_bound"] = 100_002_000 # 마지막 행은 정확히 1억이어야 함
|
||||||
|
with pytest.raises(BaseTableError):
|
||||||
|
_validate(rows)
|
||||||
|
rows = _rows()
|
||||||
|
rows[0]["anchoring_value"] = 0.5 # 값 범위(0.01~0.20) 밖
|
||||||
|
with pytest.raises(BaseTableError):
|
||||||
|
_validate(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ── §11.3 누적 전량 평가 (유통 코드1, δ=20, before=10) ────
|
||||||
|
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_pending_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
|
||||||
|
all_fail = [F] * 10
|
||||||
|
assert evaluate_pending(100, all_fail, 2) == 90 # 제조 −10
|
||||||
|
assert evaluate_pending(100, all_fail, 3) == 85 # 총판 −15
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_pending_clamp_upper():
|
||||||
|
assert evaluate_pending(200, _pending(9, 1), 1) == 200 # 상한 clamp — 조정 레코드는 호출측이 INSERT
|
||||||
|
|
||||||
|
|
||||||
|
# ── §11.4 파생 판정 ("가격 흔적" 기준) ────────────────────
|
||||||
|
def test_judge_sample_type():
|
||||||
|
# judge_sample_type(is_done, bid_price, last_offered_price, anchor_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)
|
||||||
|
assert judge_sample_type(False, None, 25_000, 24_000) == F # 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)
|
||||||
|
assert judge_sample_type(False, None, None, 24_000) == E # 가격 흔적 없음(미참여·무가격 이탈)
|
||||||
|
assert judge_sample_type(True, 24_000, 24_000, None) == E # anchor 박제 없음 → 제외
|
||||||
|
|
||||||
|
|
||||||
|
# ── §4.5 현재 값 조회 ────────────────────────────────────
|
||||||
|
def test_get_current_rate():
|
||||||
|
assert get_current_rate(70, 0) == 70
|
||||||
|
assert get_current_rate(None, 0) == 10 # 이력 없으면 정적 테이블 시작값
|
||||||
Loading…
Reference in New Issue
Block a user