[fix] negosium/backend·front: 마감된 협상이 '협상 대기'로 보이던 문제 + 종료 협상 결과 보기
- 목록 상태를 파생 계산(_effective_status): 견적 마감/마감시간 경과 + 협상생성(1) → 미참여(4). 표시·상태필터·기본정렬 그룹에 같이 적용해 탭·KPI 카운트가 배지와 어긋나지 않게 함(DB 쓰기 없음). 마감 일괄정리 이후 생긴 세션이 '협상 대기'에 남아 눌러도 마감 에러가 나던 헛클릭 제거. - 미참여·협상거부 세션도 채팅 init 허용 → '결과 보기'로 지난 대화 열람. 대화 재개는 send 가 협상중만 허용해 읽기 전용. 열 게 없는 건은 목록 has_chat(대화 이력 유무)으로 버튼 자체를 감춤. - 종료 건은 참여 API 를 타지 않고 채팅으로 직행, 채팅 액션바는 목록 복귀만 남겨 입력 잠금. - chain_max_round 가 단일컬럼 select 결과를 행 튜플로 읽어 매 목록 호출마다 예외 → 체인 최대차수 0 → 옛 라운드에도 재협상 요청이 뜨고 통과되던 버그 수정(+ABC 에 중복된 죽은 구현 제거).
This commit is contained in:
parent
e816bfbba4
commit
e1e519c20c
@ -1,18 +1,33 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import case, cast, func, nulls_last, or_, select, text, update
|
||||
from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items, quotations, sessions
|
||||
from common.database.model.models import chats, items, quotations, sessions
|
||||
from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus
|
||||
from common.logger import LOG
|
||||
|
||||
|
||||
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
|
||||
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
|
||||
|
||||
|
||||
def _effective_status():
|
||||
"""표시용 세션 상태. 견적이 마감됐거나 마감시간이 지났으면 협상생성(1)은 더 참여할 수 없으므로 미참여(4)로 본다.
|
||||
|
||||
참여/채팅진입이 진입 시점에 하는 전이(negotiation_service._load_actionable_session, chat_service.init)와 같은 규칙을
|
||||
목록에서는 쓰기 없이 파생으로만 맞춘다. 마감 일괄정리 이후에 만들어진 세션도 '협상 대기'로 남지 않는다.
|
||||
"""
|
||||
ended = or_(quotations.status == QuotationStatus.CLOSED.value, quotations.end_time < func.now())
|
||||
return case(
|
||||
(and_(sessions.status == SessionStatus.CREATED.value, ended), SessionStatus.NOT_PARTICIPATED.value),
|
||||
else_=sessions.status,
|
||||
)
|
||||
|
||||
|
||||
class ISessionCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]:
|
||||
@ -47,19 +62,6 @@ class ISessionCRUD(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]:
|
||||
# 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다.
|
||||
try:
|
||||
query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, 0
|
||||
top = rows[0][0] if rows and rows[0] else None
|
||||
return ErrorType.SUCCESS, int(top or 0)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType:
|
||||
pass
|
||||
|
||||
@ -73,7 +75,8 @@ class SessionCRUD(ISessionCRUD):
|
||||
def __filters(supplier_id, status, qt_type, keyword=None, result=None):
|
||||
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
|
||||
if status is not None:
|
||||
conds.append(sessions.status == status)
|
||||
# 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다.
|
||||
conds.append(_effective_status() == status)
|
||||
if qt_type is not None:
|
||||
conds.append(sessions.qt_type == qt_type)
|
||||
# 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다.
|
||||
@ -113,7 +116,7 @@ class SessionCRUD(ISessionCRUD):
|
||||
else:
|
||||
# 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고
|
||||
# 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지.
|
||||
actionable = sessions.status.in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
||||
actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value))
|
||||
status_rank = case((actionable, 0), else_=1)
|
||||
action_order = case((actionable, quotations.end_time), else_=None).asc()
|
||||
done_order = case((~actionable, quotations.end_time), else_=None).desc()
|
||||
@ -122,7 +125,7 @@ class SessionCRUD(ISessionCRUD):
|
||||
query = (
|
||||
select(
|
||||
sessions.session_id,
|
||||
sessions.status,
|
||||
_effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다
|
||||
sessions.qt_type,
|
||||
sessions.qt_number,
|
||||
quotations.end_time, # qt_end_time = 견적 마감 시각
|
||||
@ -136,6 +139,8 @@ class SessionCRUD(ISessionCRUD):
|
||||
quotations.round,
|
||||
quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰
|
||||
sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조
|
||||
# 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다.
|
||||
select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712
|
||||
)
|
||||
.join(items, items.item_id == sessions.item_id)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
@ -231,7 +236,8 @@ class SessionCRUD(ISessionCRUD):
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, 0
|
||||
top = rows[0][0] if rows and rows[0] else None
|
||||
# 단일 컬럼 select 는 scalars() 로 내려와 rows 가 값 리스트다(행 튜플이 아님).
|
||||
top = rows[0] if rows else None
|
||||
return ErrorType.SUCCESS, int(top or 0)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -21,6 +21,7 @@ class ListItem(WebPacketProtocol):
|
||||
renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0")
|
||||
renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열")
|
||||
result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)")
|
||||
has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용")
|
||||
|
||||
|
||||
class Res_SessionList(Res_WebPacketProtocol):
|
||||
|
||||
@ -227,11 +227,8 @@ class ChatService:
|
||||
)
|
||||
sess.status = SessionStatus.NOT_PARTICIPATED.value
|
||||
|
||||
# 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙).
|
||||
# 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다.
|
||||
if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value):
|
||||
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
||||
return res
|
||||
# 미참여/협상거부 세션도 '결과 보기'로 지난 대화를 열람할 수 있다(중간 이탈·거부로 끝난 건).
|
||||
# 대화 재개는 send() 가 협상중(2)만 허용하므로 여기서 막지 않아도 읽기 전용이다.
|
||||
|
||||
await self._ensure_in_progress(sess, quote)
|
||||
|
||||
|
||||
@ -182,6 +182,7 @@ class NegotiationService:
|
||||
renegotiation_status=status,
|
||||
renegotiation_memo=renego.get("memo") or "",
|
||||
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
|
||||
has_chat=bool(r[15]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -380,23 +380,28 @@ async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine)
|
||||
|
||||
|
||||
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
|
||||
"""검증: 마감시간이 지난 협상생성 세션으로 채팅 진입.
|
||||
기대결과: DB 상태가 미참여(4)로 정리되고, init 자체는 열람용으로 성공한다."""
|
||||
token = await _login_token(client)
|
||||
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
||||
body = (await _init(client, token, sid)).json()
|
||||
# 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다.
|
||||
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
||||
assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨
|
||||
assert body["result"]["success"] is True
|
||||
assert body["session_status"] == 4
|
||||
assert await _session_status(db_engine, sid) == 4 # DB 도 미참여로 전이
|
||||
|
||||
|
||||
async def test_init_blocks_rejected_session(client, chat_seed, db_engine):
|
||||
async def test_init_allows_viewing_rejected_session(client, chat_seed, db_engine):
|
||||
"""검증: 협상거부(5)로 끝난 세션에 '결과 보기'로 재진입.
|
||||
기대결과: init 성공(열람 허용) — 대화 재개는 send 가 협상중만 허용해 막는다."""
|
||||
token = await _login_token(client)
|
||||
sid = chat_seed["sids"]["P"]
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부
|
||||
body = (await _init(client, token, sid)).json()
|
||||
assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단
|
||||
assert body["result"]["success"] is True and body["session_status"] == 5
|
||||
assert (await _send(client, token, sid, "네")).json()["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
|
||||
|
||||
|
||||
# ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ----------
|
||||
|
||||
@ -16,6 +16,35 @@ TEST_SUPPLIER_NAME = "파이테스트협상공급사"
|
||||
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
||||
|
||||
|
||||
async def _seed_case(conn, code, sess_st, qt_type, hrs, quote_st, sup):
|
||||
"""상품·견적·세션 1세트 시드. 코드/견적번호에 MARK prefix 를 달아 cleanup 이 함께 지운다.
|
||||
hrs 는 마감(quotation.end_time)까지의 시간 — 음수면 이미 마감시간이 지난 건. 반환: (session_id, qt_id)."""
|
||||
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
||||
),
|
||||
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
|
||||
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
|
||||
),
|
||||
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO negotiation.sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
||||
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
|
||||
),
|
||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
||||
)
|
||||
return session_id, qt_id
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def nego_seed(db_engine):
|
||||
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
||||
@ -33,6 +62,11 @@ async def nego_seed(db_engine):
|
||||
sids, qids = {}, {}
|
||||
|
||||
async def _cleanup(conn):
|
||||
# 대화는 세션보다 먼저 지운다(세션이 사라지면 대상을 못 고른다).
|
||||
await conn.execute(text(
|
||||
f"DELETE FROM negotiation.chats WHERE session_id IN "
|
||||
f"(SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"
|
||||
))
|
||||
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
||||
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
||||
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
||||
@ -52,31 +86,9 @@ async def nego_seed(db_engine):
|
||||
),
|
||||
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
||||
)
|
||||
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
|
||||
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
sids[code], qids[code] = session_id, qt_id
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
||||
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
||||
),
|
||||
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
|
||||
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
|
||||
),
|
||||
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO negotiation.sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
||||
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
|
||||
),
|
||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
||||
)
|
||||
for spec in specs:
|
||||
code = spec[0]
|
||||
sids[code], qids[code] = await _seed_case(conn, *spec)
|
||||
|
||||
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
||||
|
||||
@ -141,6 +153,63 @@ async def test_list_filter_status(client, nego_seed):
|
||||
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
||||
|
||||
|
||||
async def test_list_shows_closed_quotation_created_session_as_not_participated(client, db_engine, nego_seed):
|
||||
"""검증: 견적이 마감(3)된 뒤에도 세션이 협상생성(1)으로 남아 있는 건(마감 일괄정리 이후 생성 등).
|
||||
기대결과: 목록 상태는 미참여(4) — '협상 대기'로 새지 않고, status=1 필터에서도 빠지고 status=4 필터에 잡힌다."""
|
||||
async with db_engine.begin() as conn:
|
||||
await _seed_case(conn, "CLOSED1", 1, 2, -1, 3, nego_seed["supplier_id"])
|
||||
token = await _login_token(client)
|
||||
|
||||
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CLOSED1")
|
||||
assert listed["session_status"] == 4
|
||||
|
||||
waiting = (await _list(client, token, status=1)).json()
|
||||
assert waiting["total"] == 1 and {i["item_code"] for i in waiting["items"]} == {f"{MARK}A"}
|
||||
assert f"{MARK}CLOSED1" in {i["item_code"] for i in (await _list(client, token, status=4)).json()["items"]}
|
||||
|
||||
|
||||
async def test_list_shows_deadline_passed_created_session_as_not_participated(client, db_engine, nego_seed):
|
||||
"""검증: 견적은 아직 진행중(2)인데 마감시간(end_time)만 지난 협상생성 세션.
|
||||
기대결과: 미참여(4) — 참여/입장이 막히는 건이라 목록도 같은 상태로 보인다(DB 값은 그대로)."""
|
||||
async with db_engine.begin() as conn:
|
||||
session_id, _ = await _seed_case(conn, "OVERDUE", 1, 2, -3, 2, nego_seed["supplier_id"])
|
||||
token = await _login_token(client)
|
||||
|
||||
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}OVERDUE")
|
||||
assert listed["session_status"] == 4
|
||||
assert await _session_status(db_engine, session_id) == 1 # 목록은 파생 표시만, 쓰기는 하지 않는다
|
||||
|
||||
|
||||
async def test_list_marks_stale_round_not_renegotiable(client, db_engine, nego_seed):
|
||||
"""검증: 개찰(결렬) 마감된 1차 견적에 2차가 이미 생성돼 있는 체인.
|
||||
기대결과: renegotiable False — 다음 라운드가 있으면 재협상 요청 대상이 아니다(체인 최대 차수 판정)."""
|
||||
async with db_engine.begin() as conn:
|
||||
await _seed_case(conn, "CHAIN", 3, 2, -2, 3, nego_seed["supplier_id"])
|
||||
await conn.execute(text(
|
||||
f"UPDATE quotation.quotations SET close_reason = 5 WHERE number = '{MARK}CHAIN'"))
|
||||
# 같은 견적번호의 2차 — 번호가 같아야 체인으로 묶인다.
|
||||
await conn.execute(text(
|
||||
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, round, start_time, end_time) "
|
||||
f"VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '견적 CHAIN 2차', '{MARK}CHAIN', 2, 2, 2, now(), now() + make_interval(hours => 2))"))
|
||||
token = await _login_token(client)
|
||||
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CHAIN")
|
||||
assert listed["result"] == 3 and listed["renegotiable"] is False
|
||||
|
||||
|
||||
async def test_list_has_chat_flags_sessions_with_history(client, db_engine, nego_seed):
|
||||
"""검증: 대화 이력이 있는 세션과 없는 세션의 has_chat.
|
||||
기대결과: 이력 있는 건만 True — 종료 건의 '결과 보기' 노출이 이 값으로 갈린다."""
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 1, 1, 0)"),
|
||||
{"sid": nego_seed["sids"]["C"]},
|
||||
)
|
||||
token = await _login_token(client)
|
||||
by_code = {i["item_code"]: i["has_chat"] for i in (await _list(client, token)).json()["items"]}
|
||||
assert by_code[f"{MARK}C"] is True
|
||||
assert by_code[f"{MARK}A"] is False
|
||||
|
||||
|
||||
async def test_list_filter_qt_type(client, nego_seed):
|
||||
token = await _login_token(client)
|
||||
body = (await _list(client, token, qt_type=2)).json()
|
||||
|
||||
@ -62,6 +62,7 @@ export interface SessionListItem {
|
||||
renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0
|
||||
renegotiation_memo: string // 담당자 심사 메모(반려 사유)
|
||||
result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||
has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용
|
||||
}
|
||||
|
||||
/** 공급사 관점 협상 결과 (sessions 파생) */
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
import { SessionStatus } from '@/apis'
|
||||
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import { ChatMessage } from '@/features/chat/components/ChatMessage'
|
||||
import { UserButton } from '@/features/chat/components/UserButton'
|
||||
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
|
||||
import { MobileStepBar } from '@/features/chat/components/MobileStepBar'
|
||||
import type { UserButtonConfig } from '@/features/chat/types'
|
||||
|
||||
// 미참여·협상거부로 끝난 세션은 '결과 보기'(열람) 진입이라 입력을 잠근다.
|
||||
// 마지막 봇 메시지가 입력을 요구하는 상태로 멈춰 있어도(중간 이탈) 서버가 전송을 막으므로 액션바는 복귀 버튼만 둔다.
|
||||
const READ_ONLY_STATUSES: number[] = [SessionStatus.NOT_PARTICIPATED, SessionStatus.REJECTED]
|
||||
const READ_ONLY_CONFIG: UserButtonConfig = { type: 'one-black', text: GO_TO_LIST_TEXT }
|
||||
|
||||
export function ChatSection() {
|
||||
const { userButtonConfig } = useChatStore()
|
||||
const sessionStatus = useChatInitStore((s) => s.session_status)
|
||||
const config = READ_ONLY_STATUSES.includes(sessionStatus) ? READ_ONLY_CONFIG : userButtonConfig
|
||||
return (
|
||||
<div className="flex flex-1 flex-col w-full h-full min-h-0 bg-surface">
|
||||
<MobileStepBar />
|
||||
@ -12,7 +23,7 @@ export function ChatSection() {
|
||||
{/* 하단 액션 덱 */}
|
||||
{/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
|
||||
<div className="shrink-0 safe-b border-t border-border bg-white shadow-[0_-4px_20px_rgba(0,0,0,0.03)]">
|
||||
<UserButton {...userButtonConfig} />
|
||||
<UserButton {...config} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -24,8 +24,9 @@ const TERMINAL_CODES = new Set<number>([
|
||||
ErrorCode.NEGO_NOT_FOUND,
|
||||
])
|
||||
|
||||
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 /
|
||||
// 진입 불가 상태(미참여·거부). 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다)
|
||||
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / 진입 불가 상태.
|
||||
// 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다)
|
||||
// 종료된 세션(완료·미참여·거부)은 열람 진입이 허용되므로 여기 걸리지 않는다 — 입력만 잠긴다(ChatSection).
|
||||
const INVALID_ACCESS_CODES = new Set<number>([
|
||||
ErrorCode.NEGO_FORBIDDEN,
|
||||
ErrorCode.NEGO_NOT_FOUND,
|
||||
@ -115,8 +116,8 @@ export function useChatController(sessionId: string) {
|
||||
if (data.message) s.appendMessage(mapMessage(data.message))
|
||||
s.setIsLoading(false)
|
||||
// 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다.
|
||||
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에
|
||||
// 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
|
||||
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 방금 종료한 화면의 입력이 열람 모드로
|
||||
// 갈아치워지지 않게 한다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
|
||||
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
|
||||
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { Loader2, MessageSquareText } from 'lucide-react'
|
||||
import { cn, formatKstDateTime } from '@/lib'
|
||||
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
|
||||
import type { ListItem } from '@/features/list/types'
|
||||
import { statusMeta, RESULT_META } from '@/features/list/lib/status'
|
||||
import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status'
|
||||
|
||||
interface WorkspaceCardsProps {
|
||||
items: ListItem[]
|
||||
@ -55,10 +55,11 @@ function Card({
|
||||
onMemo: (item: ListItem) => void
|
||||
}) {
|
||||
const meta = statusMeta(item.session_status)
|
||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
||||
const ended = isEndedStatus(item.session_status)
|
||||
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||
const isDone = item.session_status === '협상완료'
|
||||
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
||||
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
||||
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
|
||||
const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? ''
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { Loader2, MessageSquareText } from 'lucide-react'
|
||||
import { cn, formatKstDateTime } from '@/lib'
|
||||
import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type'
|
||||
import type { ListItem } from '@/features/list/types'
|
||||
import { statusMeta, RESULT_META } from '@/features/list/lib/status'
|
||||
import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status'
|
||||
|
||||
interface WorkspaceTableProps {
|
||||
items: ListItem[]
|
||||
@ -73,10 +73,11 @@ function Row({
|
||||
onMemo: (item: ListItem) => void
|
||||
}) {
|
||||
const meta = statusMeta(item.session_status)
|
||||
const canEnter = !['미참여', '협상거부'].includes(item.session_status)
|
||||
const ended = isEndedStatus(item.session_status)
|
||||
const canEnter = canEnterChat(item.session_status, item.hasChat)
|
||||
const canReject = ['협상생성', '협상중'].includes(item.session_status)
|
||||
const isDone = item.session_status === '협상완료'
|
||||
const enterLabel = isDone ? '결과 보기' : '협상 입장'
|
||||
const enterLabel = ended ? '결과 보기' : '협상 입장'
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
|
||||
|
||||
@ -24,6 +24,7 @@ import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
|
||||
import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup'
|
||||
import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup'
|
||||
import { GuidePopup } from '@/features/list/components/GuidePopup'
|
||||
import { isEndedStatus } from '@/features/list/lib/status'
|
||||
import type { ListItem } from '@/features/list/types'
|
||||
|
||||
// 상태별 거부 불가 안내
|
||||
@ -70,6 +71,11 @@ export function ListWorkspace() {
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
|
||||
const handleEnter = (item: ListItem) => {
|
||||
// 종료 건(완료·미참여·거부)은 열람 전용 — 참여 API 는 미참여/거부를 막으므로 바로 채팅으로 보낸다.
|
||||
if (isEndedStatus(item.session_status)) {
|
||||
navigate(`/chat?session_id=${item.session_id}`)
|
||||
return
|
||||
}
|
||||
setEnteringId(item.session_id)
|
||||
participate.mutate(item.session_id, {
|
||||
onSuccess: () => navigate(`/chat?session_id=${item.session_id}`),
|
||||
|
||||
@ -42,5 +42,6 @@ export function toListItem(api: SessionListItem): ListItem {
|
||||
renegotiationStatus: api.renegotiation_status ?? 0,
|
||||
renegotiationMemo: api.renegotiation_memo ?? '',
|
||||
result: api.result ?? 0,
|
||||
hasChat: api.has_chat ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,18 @@ export function statusMeta(label: string): StatusMeta {
|
||||
return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' }
|
||||
}
|
||||
|
||||
// 종료 상태 — 대화를 이어갈 수 없고 지난 이력만 '결과 보기'로 열람한다(백엔드 send 도 협상중만 허용).
|
||||
const ENDED_STATUSES = ['협상완료', '미참여', '협상거부']
|
||||
|
||||
export function isEndedStatus(label: string): boolean {
|
||||
return ENDED_STATUSES.includes(label)
|
||||
}
|
||||
|
||||
// 채팅 진입 가능 여부. 진행 건은 항상, 종료 건은 볼 대화가 있을 때만 — 초청만 받고 끝난 건은 열어도 빈 화면이다.
|
||||
export function canEnterChat(label: string, hasChat: boolean): boolean {
|
||||
return !isEndedStatus(label) || hasChat
|
||||
}
|
||||
|
||||
// 협상 결과(SessionResult 코드) → 완료 건에 붙는 결과 배지. 0(미정)은 표시하지 않는다.
|
||||
// 결렬(3)은 재협상 요청 대상이라 눈에 띄게 노랑으로 둔다.
|
||||
export const RESULT_META: Record<number, { label: string; badge: string }> = {
|
||||
|
||||
@ -13,4 +13,5 @@ export type ListItem = {
|
||||
renegotiationStatus: number // 0=없음 1=심사중 2=승인 3=반려 4=철회
|
||||
renegotiationMemo: string // 담당자 심사 메모(반려 사유)
|
||||
result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰)
|
||||
hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user