diff --git a/docker-compose.yml b/docker-compose.yml index 558f0ea..9f145e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,11 +8,13 @@ # negosium 프론트: http://localhost:3300 # negodata 서버: http://localhost:9400/docs # agent 서버: http://localhost:9500/docs +# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인 # # DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다. # psql -h -p -U -f postgres-init/01-schema*.sql (단일 negosium_db + 도메인별 schema) # psql -h -p -U -f postgres-init/02-learning-schema.sql (agent learning 스키마) # psql -h -p -U -f postgres-init/03-seed-negodata.sql (negodata 전용 시드: admin / admin1234, company.users) +# psql -h -p -U -f postgres-init/05-anchoring-schema.sql (anchoring 스키마: adjustments·뷰 — schedules/anchoring 소유) services: negosium-backend: @@ -77,3 +79,37 @@ services: ports: - "3300:3300" restart: unless-stopped + + # 앵커링 값 자동 조정 배치 (negosium_db 공유, 포트 없음 — 상주 스케줄러). + anchoring: + build: ./schedules/anchoring + container_name: anchoring + environment: + DB_HOST: host.docker.internal # 컨테이너→호스트 DB + REDIS_HOST: anchoring-redis + TZ: Asia/Seoul + volumes: + - ./schedules/anchoring/config.toml:/app/config.toml:ro # 시크릿은 마운트 — up 전에 파일 필요 + depends_on: + - anchoring-redis + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + logging: # 상주 배치 — 장기 운영 디스크 보호 + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # 앵커링 조회 캐시 (anchoring 전용) + anchoring-redis: + image: redis:7-alpine + container_name: anchoring-redis + ports: + - "127.0.0.1:6379:6379" # 호스트 로컬만 개방 (무인증 Redis) + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" diff --git a/negodata/backend/common/anchoring/reader.py b/negodata/backend/common/anchoring/reader.py index 0f3d581..ac7a93b 100644 --- a/negodata/backend/common/anchoring/reader.py +++ b/negodata/backend/common/anchoring/reader.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.anchoring.constants import ANCHORING_VALUE_MAX, ANCHORING_VALUE_MIN, SAMPLEABLE_SUPPLIER_TYPES from common.logger import LOG -# 모듈 소유 DDL(schedules/anchoring/schema.sql)의 조회용 뷰 — negodata 는 ORM 모델 없이 읽기만 한다. +# 모듈 소유 DDL(postgres-init/05-anchoring-schema.sql)의 조회용 뷰 — negodata 는 ORM 모델 없이 읽기만 한다. _current_values = table( "current_values", column("company_id"), diff --git a/negodata/backend/tests/test_quotation_anchoring.py b/negodata/backend/tests/test_quotation_anchoring.py index 3290537..7ef8947 100644 --- a/negodata/backend/tests/test_quotation_anchoring.py +++ b/negodata/backend/tests/test_quotation_anchoring.py @@ -158,7 +158,7 @@ async def _drop_anchoring(engine): await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE")) -# 모듈 소유 DDL(schedules/anchoring/schema.sql)에서 조회 경로에 필요한 부분 발췌. +# 모듈 소유 DDL(postgres-init/05-anchoring-schema.sql)에서 조회 경로에 필요한 부분 발췌. # negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다. _ANCHORING_DDL = ( "CREATE SCHEMA IF NOT EXISTS anchoring", diff --git a/postgres-init/04-alter_20260702.sql b/postgres-init/04-alter_20260702.sql index 7ee1605..f126568 100644 --- a/postgres-init/04-alter_20260702.sql +++ b/postgres-init/04-alter_20260702.sql @@ -55,7 +55,7 @@ CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notification -- ───────────────────────────────────────────────────────────── -- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종 -- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(adjustments·뷰)는 --- 모듈 소유 DDL schedules/anchoring/schema.sql 로 적용 — 여기엔 두지 않는다.) +-- 05-anchoring-schema.sql 로 적용 — 여기엔 두지 않는다.) ALTER TABLE negotiation.sessions ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제 ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적) diff --git a/schedules/anchoring/schema.sql b/postgres-init/05-anchoring-schema.sql similarity index 71% rename from schedules/anchoring/schema.sql rename to postgres-init/05-anchoring-schema.sql index 4a309fb..d11aead 100644 --- a/schedules/anchoring/schema.sql +++ b/postgres-init/05-anchoring-schema.sql @@ -1,16 +1,20 @@ -- ============================================================ --- anchoring 모듈 DDL (모듈 소유 — postgres-init 에 두지 않는다) --- 적용: psql -h -U -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). --- 구 이름(rate_adjustments 등)에서 넘어오는 기존 DB 는 migrations/20260706_rename_anchoring.sql 적용. +-- anchoring : 앵커링 값 자동 조정 배치 (schedules/anchoring) 자산 -- ============================================================ +-- negosium_db 안의 8번째 schema. schedules/anchoring 서비스가 소유한다(backend 는 소비만). +-- 01-schema*.sql 과 동일 컨벤션: FK 미사용(앱 레이어 무결성), TIMESTAMPTZ(UTC), 코드값 SMALLINT. +-- 규범 문서: schedules/anchoring/docs/개발용.md §6. +-- +-- sessions 의 앵커링 컬럼(anchoring_price·anchoring_value·last_offer_price·used_by_adjustment_id)은 +-- 여기 두지 않는다 — 신규 DB 는 01-schema*.sql, 기존 DB 는 04-alter*.sql 소관. +-- 유일한 DDL 원본 — 구 schedules/anchoring/schema.sql 은 여기로 이관 후 삭제(2026-07-06). +-- 구 이름(rate_adjustments 등)의 기존 DB 는 schedules/anchoring/migrations/20260706_rename_anchoring.sql 적용. + \connect negosium_db CREATE SCHEMA IF NOT EXISTS anchoring; --- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략. +-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지, updated_at/deleted 의도적 생략. CREATE TABLE IF NOT EXISTS anchoring.adjustments ( adjustment_id BIGSERIAL PRIMARY KEY, company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래) @@ -28,12 +32,6 @@ CREATE TABLE IF NOT EXISTS anchoring.adjustments ( CREATE INDEX IF NOT EXISTS idx_adjustments_cell ON anchoring.adjustments (company_id, supplier_type, price_range_index, adjustment_id DESC); --- 선행요건 §13 + 소비 마킹. anchoring_price 는 기존 컬럼(negodata 가 생성 시 박제). -ALTER TABLE negotiation.sessions - ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(‰) 박제 - ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 가격 입력마다 backend 가 갱신, 종료 후 불변. NULL=가격 흔적 없음(표본 제외) - ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id - -- 배치 스캔 최적화: 미처리 "재협상" 세션만 (부분 인덱스). -- qt_type=1 을 술어에 포함해야 함 — 빼면 배치가 마킹하지 않는 비재협상 세션이 -- 영구 잔류해 인덱스가 전체 세션 수에 비례해 성장한다(의도는 이월 풀만 담는 소형 인덱스). @@ -41,9 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending ON negotiation.sessions (status) WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1; --- ============================================================ --- 조회용 뷰 (파생 — 상태 없음, 진실 원천은 adjustments) --- ============================================================ +-- ── 조회용 뷰 (파생 — 상태 없음, 진실 원천은 adjustments) ────────── -- 회사별 앵커링 값 변경 이력 리스트업: "언제, 어떤 칸이, 몇 건 중 몇 건 성공으로, 몇 ‰에서 몇 ‰로" CREATE OR REPLACE VIEW anchoring.value_history AS diff --git a/schedules/anchoring/README.md b/schedules/anchoring/README.md index ea532aa..11dd214 100644 --- a/schedules/anchoring/README.md +++ b/schedules/anchoring/README.md @@ -19,25 +19,25 @@ ## 구조 ``` -schema.sql # 모듈 소유 DDL (adjustments + sessions 3컬럼) — psql 수동 적용 src/anchoring/ constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의) resources/anchoring_base.json # 정적 기본 테이블(46칸 사다리, 전부 10‰) — 불변, 시작값의 유일한 소스 base_table.py # 로드+검증(실패 시 기동 중단) service.py # 순수 계산 (구간·앵커가·판정·평가) — negodata 이식 대상 - reader.py # 현재 rate 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상 + reader.py # 현재 anchoring_value 조회: 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 없으면 자동 스킵) +migrations/ # 기존 DB 이름 개편용 rename 마이그레이션 (DDL 자체는 postgres-init/05) ``` ## 실행 ```bash -# 0) DDL 적용 (신규 DB: postgres-init/01~04 이후) -psql -h 127.0.0.1 -U postgres -d negosium_db -f schema.sql +# 0) DDL 적용 — 루트 postgres-init 로 이관됨 (신규 DB: 01~04 이후 05) +psql -h 127.0.0.1 -U postgres -d negosium_db -f ../../postgres-init/05-anchoring-schema.sql # 로컬(가상환경) python3 -m venv .venv && .venv/bin/pip install -r requirements.txt @@ -46,9 +46,9 @@ PYTHONPATH=src .venv/bin/python -m anchoring.main --once --dry-run # 예행 연 PYTHONPATH=src .venv/bin/python -m anchoring.main --once # 수동 1회(격주 게이트 무시) PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주 -# 도커(자립 compose: redis 동봉) — config.toml 은 이미지에 안 들어가고 마운트되므로 -# up 전에 파일이 먼저 있어야 한다 (없으면 docker 가 디렉터리를 만들어 기동 실패) -docker compose up -d --build +# 도커 — 루트 docker-compose.yml 에 통합됨(anchoring + anchoring-redis). config.toml 은 +# 이미지에 안 들어가고 마운트되므로 up 전에 파일이 먼저 있어야 한다 (레포 루트에서 실행) +(cd ../.. && docker compose up -d --build anchoring anchoring-redis) # 테스트 PYTHONPATH=src .venv/bin/python -m pytest tests/ -q @@ -65,7 +65,7 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만 - 타임스탬프는 항상 KST. 회차마다 `조정 company=... n=13 성공=8 10‰→30‰ adj_id=26`(칸별 상세)과 `회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id` 로 `anchoring.adjustments` 행과 교차 확인한다. - 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다. -- 로그 로테이션은 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당. +- 로그 로테이션은 루트 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당. ## 운영 런북 diff --git a/schedules/anchoring/docker-compose.yml b/schedules/anchoring/docker-compose.yml deleted file mode 100644 index d414f9f..0000000 --- a/schedules/anchoring/docker-compose.yml +++ /dev/null @@ -1,34 +0,0 @@ -# anchoring 자립 서비스 — 루트 compose 와 독립(다른 서버를 건드리지 않음). -# DB 는 기존 외부 PostgreSQL(host.docker.internal), Redis 는 여기 동봉. -# negodata(견적 생성 측)는 이 redis 인스턴스를 REDIS_HOST 로 바라본다(docs/인수인계.md). -# 로그: stdout(json-file) — 로테이션 필수(장기 운영 디스크 보호). 로그 시각은 코드가 KST 로 고정. -x-logging: &default-logging - driver: json-file - options: - max-size: "10m" - max-file: "5" - -services: - anchoring-redis: - image: redis:7-alpine - container_name: anchoring-redis - ports: - - "127.0.0.1:6379:6379" # 호스트 로컬만 — 무인증 Redis 를 외부에 열지 않는다(앵커 값 오염 방지) - restart: unless-stopped - logging: *default-logging - - anchoring: - build: . - container_name: anchoring - environment: - DB_HOST: host.docker.internal - REDIS_HOST: anchoring-redis - TZ: Asia/Seoul - volumes: - # config.toml(시크릿)은 이미지에 안 굽고 런타임 마운트 — 없으면 파일을 먼저 만들 것 - # (cp config.toml.example config.toml — 없이 up 하면 docker 가 디렉터리를 만들어 기동 실패) - - ./config.toml:/app/config.toml:ro - depends_on: - - anchoring-redis - restart: unless-stopped - logging: *default-logging diff --git a/schedules/anchoring/docs/개발용.md b/schedules/anchoring/docs/개발용.md index d9c5e40..e9a4785 100644 --- a/schedules/anchoring/docs/개발용.md +++ b/schedules/anchoring/docs/개발용.md @@ -288,7 +288,7 @@ Redis anchor:{company_id}:{supplier_type}:{price_range_index} → rate(‰), TT ## 6. DB 스키마 -프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 **모듈 소유** — `schema.sql` 한 파일(스키마+테이블+sessions ALTER+인덱스, psql 수동 적용). `postgres-init` 에는 anchoring 파일을 두지 않는다. +프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 `postgres-init/05-anchoring-schema.sql` 한 파일(스키마+테이블+뷰+인덱스, psql 수동 적용 — 2026-07-06 모듈 schema.sql 에서 이관). sessions 앵커링 컬럼은 `01-schema*.sql`·`04-alter*.sql` 소관. 소유 서비스는 여전히 이 모듈이다. **네이밍 결정** — 기존 코드베이스 용어와 통일: @@ -357,7 +357,7 @@ anchoring.current_values -- 칸별 현재값(최신 조정 행). 여기 없는 주의사항: - `sessions.anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님). -- 신규 DB 구축 시 적용 순서: `postgres-init/01~04` → `schedules/anchoring/schema.sql` (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 `postgres-init/01-schema*.sql`·`04-alter*.sql` 에도 반영돼 있다**(backend 가 모듈 DDL 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 모듈 파일만이 소유. +- 신규 DB 구축 시 적용 순서: `postgres-init/01~05` (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 `postgres-init/01-schema*.sql`·`04-alter*.sql` 에도 반영돼 있다**(backend 가 모듈 DDL 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 모듈 파일만이 소유. - backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함). --- @@ -378,13 +378,13 @@ anchoring.current_values -- 칸별 현재값(최신 조정 행). 여기 없는 - ⚠️ **stale 키는 "미스"가 나지 않는다**: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다. - 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요. - 클라이언트: `redis.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)뿐이다. backend 는 Redis 를 쓰지 않고, **negodata 도 쓰지 않는다**(2026-07-04 적용된 이식판 reader 는 `current_values` 뷰 직조회 — 인수인계 §1. Redis 캐시 전체 제거가 후속 백로그로 확정됨). 설정은 모듈 `config.toml` + `REDIS_HOST/PORT/PASSWORD` env 오버라이드. -- 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. 모듈 compose 는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다(TODO 백로그). +- 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. compose(루트 docker-compose.yml)는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다(TODO 백로그). --- ## 8. 배치 잡 명세 -- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·docker-compose·config.toml 보유, backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). 플래그는 argparse 로 검증한다 — `--dry-run` 단독(상주에 dry-run 은 없음)·미지의 플래그(오타)는 **기동 전 즉시 에러(종료코드 2)**: 예행인 줄 알고 실제 변경 상주 스케줄러가 뜨는 사고를 차단. `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지). config.toml 은 이미지에 넣지 않는다(.dockerignore 포함) — compose 가 읽기 전용 마운트하거나 env 로 주입. +- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·config.toml 보유(compose 는 루트 docker-compose.yml 에 통합), backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). 플래그는 argparse 로 검증한다 — `--dry-run` 단독(상주에 dry-run 은 없음)·미지의 플래그(오타)는 **기동 전 즉시 에러(종료코드 2)**: 예행인 줄 알고 실제 변경 상주 스케줄러가 뜨는 사고를 차단. `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지). config.toml 은 이미지에 넣지 않는다(.dockerignore 포함) — compose 가 읽기 전용 마운트하거나 env 로 주입. - **스케줄**: 매주 토 00:00 KST 트리거(`CronTrigger(day_of_week="sat", hour=0, minute=0)`) + 잡 내부에서 **ISO 주차 % 2 == EVAL_WEEK_PARITY** 격주 게이트 (기준 패리티는 상수 고정 MUST). - **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND used_by_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다. - **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.) @@ -421,7 +421,7 @@ anchoring.current_values -- 칸별 현재값(최신 조정 행). 여기 없는 **로그 규약** (운영 추적): -- 출력 = stdout(컨테이너 json-file 드라이버, compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**. +- 출력 = stdout(컨테이너 json-file 드라이버, 루트 compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**. - 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= price_range=` key=value 형식 → **회사별 grep**(`grep company=`). - 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → **칸별 조정 상세**(`n= 성공= before‰→after‰ adj_id=` — DB 행과 교차 확인) → **회사요약**(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약. - 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN. @@ -707,13 +707,13 @@ clamp·격리 케이스: | # | 항목 | 담당 | 상태 | |---|---|---|---| -| 1 | DDL — `anchoring.adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) | +| 1 | DDL — `anchoring.adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `postgres-init/05-anchoring-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 무의존 | +| 8 | Redis 인프라 | [우리 — 모듈] | 루트 docker-compose 에 redis 동봉(모듈 전용 캐시). negodata 는 무Redis(뷰 직조회)·backend 도 무의존 | | 9 | 스케줄러·배치 | [우리 — 모듈] | 자립 컨테이너(APScheduler, `--once` 수동 실행 지원). 구현 완료 | | 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offer_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 | diff --git a/schedules/anchoring/docs/운영및유지보수.md b/schedules/anchoring/docs/운영및유지보수.md index ffee753..14e3404 100644 --- a/schedules/anchoring/docs/운영및유지보수.md +++ b/schedules/anchoring/docs/운영및유지보수.md @@ -54,11 +54,11 @@ ### STEP 1 — DB 스키마 적용 (최초 1회) ```bash -cd schedules/anchoring -psql -h -U <계정> -d negosium_db -f schema.sql +psql -h -U <계정> -d negosium_db -f postgres-init/05-anchoring-schema.sql # 레포 루트에서 ``` -- 테이블 1개(`anchoring.adjustments`)·조회용 뷰 2개(`value_history`, `current_values`)와 `negotiation.sessions` 컬럼 3개를 추가합니다. +- 테이블 1개(`anchoring.adjustments`)·조회용 뷰 2개(`value_history`, `current_values`)·인덱스를 추가합니다. + (`negotiation.sessions` 앵커링 컬럼은 `postgres-init/01-schema*.sql`(신규)·`04-alter*.sql`(기존) 소관) - `IF NOT EXISTS` 라 **여러 번 실행해도 안전**합니다. ### STEP 2 — 설정 채우기 @@ -78,7 +78,8 @@ cp config.toml.example config.toml ### STEP 3-A — 도커로 실행 (운영 권장) ```bash -docker compose up -d --build +# 레포 루트에서 (anchoring 서비스는 루트 docker-compose.yml 에 통합됨) +docker compose up -d --build anchoring anchoring-redis docker logs -f anchoring # 기동 로그 확인 (아래 §4) ``` @@ -178,7 +179,7 @@ docker logs anchoring | tail -20 # 최근 상태 | 수동 배치 1회 (격주 게이트 무시) | `docker exec anchoring python -m anchoring.main --once` | | **예행 연습** (DB/Redis 무변경, 예상 결과만 로그) | `docker exec anchoring python -m anchoring.main --once --dry-run` — 첫 운영 실행 전 필수 권장 | | 재기동 | `docker compose restart anchoring` | -| 서비스 중지/시작 | `docker compose stop` / `docker compose up -d` | +| 서비스 중지/시작 | `docker compose stop anchoring anchoring-redis` / `docker compose up -d anchoring anchoring-redis` (레포 루트에서) | | 설정 변경 반영 | config.toml 수정 → `docker compose restart anchoring` (파일은 마운트라 리빌드 불필요) | | 다음 실행 예정 시각 확인 | `docker logs anchoring \| grep "다음 실행 예정"` | | 로그 레벨 올리기(디버깅) | env `LOG_LEVEL=debug` 로 재기동 | diff --git a/schedules/anchoring/docs/인수인계.md b/schedules/anchoring/docs/인수인계.md index 35a600b..eb04f56 100644 --- a/schedules/anchoring/docs/인수인계.md +++ b/schedules/anchoring/docs/인수인계.md @@ -14,8 +14,8 @@ |---|---| | `schedules/anchoring/src/anchoring/` 모듈 | `constants.py`(상수·enum) · `base_table.py`(정적 테이블 로더) · `service.py`(순수 계산 함수) · `redis_client.py` · `reader.py`(rate 조회) — **전부 async(SQLAlchemy async + redis.asyncio) 자립형이라 negodata 에 그대로 복사/이식 가능** | | `schedules/anchoring/src/anchoring/resources/anchoring_base.json` | 정적 기본 테이블 (46행 자릿수 사다리, 불변) | -| `schedules/anchoring/schema.sql` | `anchoring.adjustments` 테이블 + `negotiation.sessions` 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의) | -| `schedules/anchoring/docker-compose.yml` | anchoring 서비스 + redis 동봉 — **negodata 는 이 redis 인스턴스를 바라본다** (`REDIS_HOST` 환경변수) | +| `postgres-init/05-anchoring-schema.sql` | `anchoring.adjustments` 테이블·뷰·인덱스 — 모듈 소유 DDL, psql 수동 적용 (sessions 컬럼은 01/04 소관) | +| 루트 `docker-compose.yml` | anchoring 서비스 + redis 동봉(모듈 전용 캐시) — **negodata 는 Redis 를 쓰지 않는다**(`current_values` 뷰 직조회) | | 이 문서 | 적용 위치·변경 전후 명세 | --- @@ -89,7 +89,7 @@ sessions(..., anchoring_price=ap, anchoring_value=rate, ...) ## 3. 적용 순서 (권장) ``` -① DB 스키마 적용 (schedules/anchoring/schema.sql — adjustments + sessions 컬럼 3개) +① DB 스키마 적용 (postgres-init/05-anchoring-schema.sql — adjustments·뷰·인덱스, sessions 컬럼은 01/04) ② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작) + backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작) ③ 전환기 점프 확인 (negodata 적용 직전): diff --git a/schedules/anchoring/migrations/20260706_rename_anchoring.sql b/schedules/anchoring/migrations/20260706_rename_anchoring.sql index 89dba86..822d96d 100644 --- a/schedules/anchoring/migrations/20260706_rename_anchoring.sql +++ b/schedules/anchoring/migrations/20260706_rename_anchoring.sql @@ -2,7 +2,7 @@ -- 앵커링 이름 전면 개편 마이그레이션 (2026-07-06) -- rate_adjustments → adjustments (+컬럼 6종) -- sessions 앵커링 컬럼 4종, 뷰 2종(rate_history/current_rates → value_history/current_values) --- 대상: 구 이름 스키마가 이미 적용된 기존 DB (신규 DB 는 schema.sql 만 적용하면 됨) +-- 대상: 구 이름 스키마가 이미 적용된 기존 DB (신규 DB 는 postgres-init/01~05 적용으로 충분) -- 멱등: 각 rename 은 구 이름 존재 시에만 수행 — 재실행·부분 적용 상태에서도 안전 -- 적용: psql -h -U -d negosium_db -f migrations/20260706_rename_anchoring.sql -- ============================================================ @@ -10,7 +10,7 @@ BEGIN; --- 1) 구 이름 뷰 제거 (신 이름 뷰는 마지막에 재생성 — 정의는 schema.sql 과 동일) +-- 1) 구 이름 뷰 제거 (신 이름 뷰는 마지막에 재생성 — 정의는 postgres-init/05-anchoring-schema.sql 과 동일) DROP VIEW IF EXISTS anchoring.rate_history; DROP VIEW IF EXISTS anchoring.current_rates; @@ -83,7 +83,7 @@ BEGIN END LOOP; END $$; --- 5) 신 이름 뷰 재생성 (schema.sql §조회용 뷰와 동일 정의) +-- 5) 신 이름 뷰 재생성 (postgres-init/05-anchoring-schema.sql §조회용 뷰와 동일 정의) CREATE OR REPLACE VIEW anchoring.value_history AS SELECT adjustment_id, company_id, diff --git a/schedules/anchoring/tests/conftest.py b/schedules/anchoring/tests/conftest.py index 69f56ef..57fa80b 100644 --- a/schedules/anchoring/tests/conftest.py +++ b/schedules/anchoring/tests/conftest.py @@ -15,7 +15,7 @@ from anchoring import db as adb from anchoring.config import load_config CFG = load_config() -_SCHEMA_SQL = Path(__file__).resolve().parents[1] / "schema.sql" +_SCHEMA_SQL = Path(__file__).resolve().parents[3] / "postgres-init" / "05-anchoring-schema.sql" def _db_available() -> bool: @@ -40,7 +40,7 @@ requires_db = pytest.mark.skipif(not DB_OK, reason="로컬 Postgres(negosium_db) def _schema_statements() -> list[str]: - """schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리.""" + """05-anchoring-schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리.""" lines = [ line for line in _SCHEMA_SQL.read_text().splitlines() if not line.startswith("\\") and not line.strip().startswith("--")