o2o-negosium-original/negodata/docs/renegotiation-flow.md
Mina Choi cfee6e89d0 [feat] negodata: 권한 3단계(개발자 신설)·개발자 메뉴 그룹·디자인 시스템 페이지
- UserRole 1=일반 2=최고관리자 3=개발자(내부 운영). 개발자 계정은 회원 목록·총계에서 제외해 고객사에 노출하지 않음
- 계정 생성 시 권한 선택(일반/최고관리자) 추가, 개발자는 앱에서 부여 불가(DB 시드 전용)
- 사이드바 '개발자' 그룹 신설 — 회사 설정·디자인 시스템을 개발자에게만 노출
- /dev/design 디자인 시스템 페이지: 색 토큰·타이포·버튼·배지·입력·반경, 목록 화면 구조, 반응형 기준, URL 상태 규칙, 회사 커스터마이징 훅
- 공급사 재협상 요청(#15) 플로우 문서 — sessions.custom 기반(DDL 0)
2026-07-23 15:41:36 +09:00

144 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 공급사 재협상 요청 (IMK #15)
> 요구 원문: "협상결렬 건에 한하여 해당 공급사측 재협상 기능 추가 및 재협상 현황 확인·승인 화면 추가 요청"
기존 재생성은 **담당자가 먼저** 마감 견적에서 공급사를 골라 다음 라운드를 만드는 방향이다(`regenerate_quotation`).
#15는 방향이 반대다 — **공급사가 먼저 요청하고 담당자가 승인**하면 그 결과로 다음 라운드가 생성된다.
따라서 재생성 로직 자체는 재사용하고, 그 앞에 "요청 → 심사" 단계를 붙인다.
## 1. 용어와 범위
| 용어 | 정의 |
|---|---|
| 결렬 건 | 낙찰되지 못한 채 마감된 건. `quotations.status=CLOSED``close_reason ∈ {OPEN_PRICE(5), OPEN_EQUAL(6), OPEN_NOSHOW(7), OPEN_REJECT(8)}` |
| 요청 자격 | 그 견적의 **마지막 라운드**에 세션이 있는 공급사 본인 |
| 승인 결과 | 원 견적의 다음 라운드 생성(`regenerate_quotation` 과 동일 경로), 요청 공급사 포함 |
### 요청 가능 조건 (전부 만족)
1. 견적이 마감(`CLOSED`)이고 `close_reason``OPEN_*`**낙찰(AWARDED) 건은 불가**
2. 요청자가 그 견적 **마지막 라운드**의 세션 보유자
3. 같은 세션에 **대기(PENDING) 상태 요청이 없음** (중복 방지)
4. 해당 견적 체인에 **더 뒤 라운드가 아직 없음** (이미 재생성됐으면 요청 의미 없음)
거부(`REJECTED`)·미참여(`NOT_PARTICIPATED`) 세션도 요청은 허용한다 — 단종·품절로 거부했다가 조건이 풀리는 경우가 실제로 있다.
## 2. 상태 흐름
```
[공급사] 결렬 건 확인 → 재협상 요청(사유·희망가) → PENDING
[담당자] 요청 현황 화면에서 심사 │
├─ 승인 → APPROVED → 다음 라운드 생성 → 초청메일
└─ 반려 → REJECTED (사유 기록, 공급사에 노출)
```
요청 상태(`sessions.custom.renegotiation.status`)
| 코드 | 상태 | 설명 |
|---|---|---|
| 1 | PENDING | 접수, 담당자 심사 대기 |
| 2 | APPROVED | 승인 — 다음 라운드 생성 완료 |
| 3 | REJECTED | 반려 — 사유 기록 |
| 4 | CANCELED | 공급사가 스스로 철회(PENDING 일 때만) |
## 3. 저장 위치 — `sessions.custom` (DDL 0)
**신규 테이블을 만들지 않는다.** 재협상 요청은 세션당 1건이라 기존 `negotiation.sessions.custom` JSONB 에
`renegotiation` 키로 얹는다. 같은 컬럼의 협상완료 부가정보와 키가 갈리므로 서로 덮어쓰지 않는다.
```json
{
"std_lead_time": 30, // 기존: 협상완료 부가정보(session_fields 정의대로)
"renegotiation": {
"status": 1, // 1=PENDING 2=APPROVED 3=REJECTED 4=CANCELED
"reason": "가격 조건 재검토",
"desired_price": 15000000, // 선택
"requested_at": "2026-07-23T10:00:00Z",
"decided_by": "<user_id>", // 심사 후 채움
"decided_at": "2026-07-23T11:00:00Z",
"memo": "목표가 대비 격차가 커 반려",
"next_quotation_id": "<qt_id>"
}
}
```
조회는 `sessions` 를 견적·공급사와 조인하면서 `custom -> 'renegotiation' ->> 'status'` 로 거른다.
세션 규모(수십~수백)에서 전용 인덱스 없이 충분하다. 느려지면 다음 순으로 올린다.
1. `custom` 에 GIN 인덱스
2. 그래도 부족하면 전용 테이블(`renegotiation_requests`)로 승격 — `custom` 값을 그대로 옮기면 되므로 되돌리기 부담이 작다
### 이 방식의 제약(수용 범위)
- **요청 이력은 최신 1건만 남는다.** 반려 후 재요청하면 이전 기록을 덮어쓴다.
이력이 필요해지는 시점 = 테이블 승격 시점.
- 세션당 대기 요청 1건 제약은 DB 유니크가 아니라 **API 검증**으로 건다(기존 값이 PENDING 이면 거부).
- 쓰기는 반드시 `execute_lambda_run` 으로 — `execute_lambda` 는 커밋하지 않아 값이 조용히 사라진다.
## 4. API
### 공급사 포털 (backend)
| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/v1/negotiation/renegotiation/eligible` | 요청 가능한 결렬 건 목록(본인 공급사) |
| `POST` | `/v1/negotiation/session/{session_id}/renegotiation` | 요청 생성 `{reason, desired_price?}` |
| `GET` | `/v1/negotiation/renegotiation` | 내 요청 목록(상태·심사결과 포함) |
| `DELETE` | `/v1/negotiation/session/{session_id}/renegotiation` | 철회(PENDING 일 때만) |
### 관리자 (negodata backend)
| 메서드 | 경로 | 설명 |
|---|---|---|
| `GET` | `/v1/renegotiation/list` | 요청 현황(상태·견적·공급사 필터, 페이지네이션) |
| `POST` | `/v1/renegotiation/{session_id}/approve` | 승인 `{supplier_ids?}` — 미지정 시 요청자만 |
| `POST` | `/v1/renegotiation/{session_id}/reject` | 반려 `{memo}` |
식별자는 별도 request_id 가 아니라 **`session_id`** 다 — 세션당 요청 1건이므로 그것으로 충분하다.
권한: 승인·반려는 **해당 견적 소유자 최고관리자**(기존 `is_owner_or_admin` 그대로).
## 5. 승인 처리 로직
1. 요청 상태가 `PENDING` 인지 확인(아니면 `INVALID_REQUEST_DATA`)
2. 대상 견적이 여전히 마지막 라운드인지 재확인 — 그 사이 담당자가 수동 재생성했을 수 있다
3. `regenerate_quotation(qt_id, company_id, supplier_ids, ...)` 호출
- `supplier_ids` 기본값 = 요청 공급사 1곳
- 담당자가 화면에서 다른 공급사를 추가로 체크하면 함께 포함(같은 라운드에 묶음)
4. 성공 시 요청을 `APPROVED` 로, `next_quotation_id`·`decided_by`·`decided_at` 기록
5. 생성된 라운드는 **기존과 동일하게 초청메일 수동 발송** — 자동 발송하지 않는다(현행 정책 유지)
같은 견적에 대기 요청이 여러 건이면, 하나를 승인할 때 나머지도 함께 처리할지 담당자가 선택한다(기본: 함께 승인하여 한 라운드에 묶음).
## 6. 화면
### 공급사 포털
- **협상 목록**: 결렬 건 행에 `재협상 요청` 버튼. 이미 요청했으면 상태 배지(`심사 중` / `승인됨` / `반려됨`)로 대체
- **요청 모달**: 사유 선택(프리셋: 가격 조건 재검토 / 재고·납기 확보 / 단가 정정 / 직접 입력) + 희망가(선택) + 안내 문구
- 반려된 경우 배지에 담당자 메모를 툴팁으로 노출
### 관리자
- **신규 메뉴 `재협상 요청`**(사이드바 업무 그룹). 목록 컬럼: 요청일시 · 견적번호/차수 · 상품 · 공급사 · 사유 · 희망가 · 상태 · 심사자
- 상태 탭: `대기` / `승인` / `반려` — 기본 `대기`
- 행 클릭 → 심사 패널: 원 견적 요약(목표가·최저 투찰가·마감사유), 요청 사유·희망가, `승인` / `반려(사유 입력)`
- 승인 시 함께 포함할 공급사 체크박스(기본: 요청자만)
- **대시보드**: 대기 건수 카드 추가 — 방치 방지
## 7. 알림
- 요청 접수 → 견적 담당자에게 알림(기존 notifications 테이블 재사용)
- 승인/반려 → 공급사 포털 목록에서 상태로 확인. 메일은 승인 시 생성되는 **초청메일로 대체**(중복 발송 방지)
## 8. 구현 순서
1. (DDL 없음) 요청 payload 형태를 양쪽 백엔드에서 공유하는 상수/헬퍼로 정리
2. 공급사 포털 API(요청 생성·조회·철회) + 자격 판정
3. 관리자 API(목록·승인·반려) + 승인 시 `regenerate_quotation` 연결
4. 관리자 화면(메뉴·목록·심사 패널·대시보드 카드)
5. 공급사 포털 화면(버튼·모달·상태 배지)
6. 알림 연결
## 9. 미결 (IMK 확인 필요)
- **요청 횟수 제한**: 같은 견적 체인에서 공급사가 몇 번까지 요청 가능한가? (제안: 체인당 1회)
- **요청 기한**: 마감 후 며칠까지 허용? (제안: 7일)
- **자동 승인 옵션**: 담당자 심사 없이 자동 재생성하는 회사 설정이 필요한가? (제안: 초기엔 없음 — 승인 필수)
- **희망가 노출 범위**: 담당자에게만인지, 협상 봇의 앵커링에 반영할지 (제안: 담당자 판단 근거로만, 봇 미반영)
- **요청 이력 보존 필요 여부**: 반려 후 재요청 이력을 남겨야 하면 `sessions.custom` 대신 전용 테이블이 필요하다 (제안: 초기엔 최신 1건만)