- UserRole 1=일반 2=최고관리자 3=개발자(내부 운영). 개발자 계정은 회원 목록·총계에서 제외해 고객사에 노출하지 않음 - 계정 생성 시 권한 선택(일반/최고관리자) 추가, 개발자는 앱에서 부여 불가(DB 시드 전용) - 사이드바 '개발자' 그룹 신설 — 회사 설정·디자인 시스템을 개발자에게만 노출 - /dev/design 디자인 시스템 페이지: 색 토큰·타이포·버튼·배지·입력·반경, 목록 화면 구조, 반응형 기준, URL 상태 규칙, 회사 커스터마이징 훅 - 공급사 재협상 요청(#15) 플로우 문서 — sessions.custom 기반(DDL 0)
144 lines
8.4 KiB
Markdown
144 lines
8.4 KiB
Markdown
# 공급사 재협상 요청 (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건만)
|