conflict 수정.
This commit is contained in:
commit
a2731d97f5
@ -83,11 +83,6 @@ class INegoContextCRUD(ABC):
|
||||
매핑이 없으면 None."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]:
|
||||
"""견적의 협력사 유형(quotations.supplier_type — supplier_items 매핑 부재 시 폴백). 미지정 시 None."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||
"""상품에 연결된 협력사 수 — supplier_items 매핑 기준 distinct supplier."""
|
||||
@ -168,21 +163,6 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]:
|
||||
try:
|
||||
query = (
|
||||
select(_QUOTATIONS.c.supplier_type)
|
||||
.where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_quotation_supplier_type failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS or not rows or rows[0] is None:
|
||||
return err_type, None
|
||||
return ErrorType.SUCCESS, int(rows[0])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||
try:
|
||||
query = (
|
||||
|
||||
@ -27,8 +27,7 @@ _ONE_TO_ONE_QT_TYPES = (1, 3)
|
||||
|
||||
# 유통 코드: SupplierType(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판)
|
||||
# → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값).
|
||||
# 소스 우선순위: partner.supplier_items.supply_type(이 협력사×이 상품 매핑, 2026-07-07 신설)
|
||||
# → quotations.supplier_type(재견적 1:1 견적 기록 — 매핑 부재 시 폴백).
|
||||
# 소스: partner.supplier_items.supply_type(이 협력사×이 상품 매핑).
|
||||
_SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"}
|
||||
|
||||
|
||||
@ -42,7 +41,7 @@ class NegotiationDbContext:
|
||||
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
|
||||
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
|
||||
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
|
||||
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type → quotations.supplier_type. 미지정 시 None
|
||||
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None
|
||||
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
|
||||
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
|
||||
|
||||
@ -78,11 +77,9 @@ class NegotiationContextLoader:
|
||||
# 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값.
|
||||
_, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id)
|
||||
|
||||
# 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type) 우선.
|
||||
# 매핑이 없으면 견적 기록(quotations.supplier_type) 폴백. 미지정 시 None → 호출부 기본값.
|
||||
# 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type).
|
||||
# 매핑이 없거나 미지정이면 None → 호출부 기본값.
|
||||
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
|
||||
if not supplier_type:
|
||||
_, supplier_type = await self.crud.get_quotation_supplier_type(s, quotation_id)
|
||||
|
||||
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
|
||||
_, item_price = await self.crud.get_item_price(s, item_id)
|
||||
|
||||
@ -13,7 +13,7 @@ class Req_Chat(Req_WebPacketProtocol):
|
||||
협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형)는 요청에 싣지
|
||||
않는다 — 세션 시작 시 agent 가 DB 에서 1회 조회해 확정한다(NegotiationContextLoader):
|
||||
negotiation.sessions(qt_type·target_price·anchoring_price), partner.items(price),
|
||||
partner.suppliers(total_revenue), quotation.quotations(supplier_type), 상품별 협력사 수.
|
||||
partner.suppliers(total_revenue), partner.supplier_items(supply_type), 상품별 협력사 수.
|
||||
행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
|
||||
가격 수용률은 세션 내 라운드별 제시가로 매 턴 동적 계산: max(0, (기존 공급가−현재가)/기존 공급가)
|
||||
— 첫 제시가부터 기존 공급가 대비 인하가 반영되므로 첫 라운드도 실값. 기존 공급가 없으면 첫 제시가 기준.
|
||||
|
||||
@ -31,7 +31,7 @@ _DEFAULT_RQ_TYPE = "재협상"
|
||||
_DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가
|
||||
_DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
|
||||
_DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백
|
||||
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — quotations.supplier_type 미지정 시 폴백
|
||||
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
|
||||
|
||||
|
||||
class ChatService:
|
||||
@ -78,7 +78,7 @@ class ChatService:
|
||||
session_id=req.session_id or str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id,
|
||||
rq_type=rq_type, action_space_size=action_space_size,
|
||||
context={
|
||||
# 매출액 = suppliers.total_revenue, 유통코드 = quotations.supplier_type 매핑 (loader).
|
||||
# 매출액 = suppliers.total_revenue, 유통코드 = supplier_items.supply_type 매핑 (loader).
|
||||
# 미기재/미지정이면 기본값 폴백.
|
||||
"revenue_amount": db_ctx.revenue_amount if db_ctx and db_ctx.revenue_amount > 0 else _DEFAULT_REVENUE_AMOUNT,
|
||||
"distribution_code": db_ctx.distribution_code if db_ctx and db_ctx.distribution_code else _DEFAULT_DISTRIBUTION_CODE,
|
||||
|
||||
@ -34,7 +34,7 @@ _T_QUOTATIONS = table(
|
||||
"quotations",
|
||||
column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"),
|
||||
column("name"), column("number"), column("type"), column("status"),
|
||||
column("start_time"), column("end_time"), column("supplier_type"),
|
||||
column("start_time"), column("end_time"),
|
||||
schema="quotation",
|
||||
)
|
||||
_T_ITEMS = table(
|
||||
@ -80,7 +80,6 @@ async def test_context_loaded_from_db(db_engine):
|
||||
qt_id=qid, user_id=uuid.uuid4(), qt_setting_id=uuid.uuid4(), version_id=uuid.uuid4(),
|
||||
name="로더 테스트", number="QT-LOADER-TEST", type=3, status=2,
|
||||
start_time=now, end_time=now + timedelta(days=1),
|
||||
supplier_type=2, # manufacture(제조) → 유통 코드 "A"
|
||||
))
|
||||
|
||||
def _ins_sess(s, session_id, supplier_id):
|
||||
@ -125,7 +124,7 @@ async def test_context_loaded_from_db(db_engine):
|
||||
assert c["distribution_code"] == "B" # supplier_items.supply_type=3(총판) → B (매핑 우선)
|
||||
assert c["partner_count"] == 2 # 매핑 기준 취급 협력사 2곳 → MULTIPLE
|
||||
|
||||
# 매핑 삭제 후 새 세션(sid2) → 폴백 경로: 유통코드=quotations.supplier_type, 파트너=세션 이력
|
||||
# 매핑 삭제 후 새 세션(sid2) → 유통코드는 ChatService 기본값, 파트너는 세션 이력 폴백
|
||||
err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[DBType.MAIN.value],
|
||||
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIER_ITEMS).where(_T_SUPPLIER_ITEMS.c.item_id == iid))],
|
||||
@ -133,7 +132,7 @@ async def test_context_loaded_from_db(db_engine):
|
||||
assert err == ErrorType.SUCCESS
|
||||
await ChatService().chat(eng, Req_Chat(session_id=str(sid2)))
|
||||
c2 = (await ChatSessionRepository(eng.company_id).get(str(sid2))).context
|
||||
assert c2["distribution_code"] == "A" # 폴백: quotations.supplier_type=2(제조) → A
|
||||
assert c2["distribution_code"] == "A" # 매핑 없음 → ChatService 기본값
|
||||
assert c2["partner_count"] == 2 # 폴백: 세션 이력 distinct supplier 2곳
|
||||
finally:
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
@ -197,9 +196,6 @@ async def test_loader_with_crud_double(db_engine):
|
||||
return ErrorType.SUCCESS, 12_000_000.0
|
||||
|
||||
async def get_supply_type(self, cdb, supplier_id, item_id):
|
||||
return ErrorType.SUCCESS, None # 매핑 없음 → 견적 기록 폴백
|
||||
|
||||
async def get_quotation_supplier_type(self, cdb, quotation_id):
|
||||
return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B"
|
||||
|
||||
async def count_item_suppliers(self, cdb, item_id):
|
||||
@ -215,7 +211,7 @@ async def test_loader_with_crud_double(db_engine):
|
||||
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
|
||||
assert ctx.item_price == 7000
|
||||
assert ctx.revenue_amount == 12_000_000.0
|
||||
assert ctx.distribution_code == "B" # supplier_type=3(총판) → B
|
||||
assert ctx.distribution_code == "B" # supply_type=3(총판) → B
|
||||
assert ctx.partner_type is PartnerType.NONE
|
||||
|
||||
|
||||
|
||||
@ -152,7 +152,6 @@ class quotations(MAIN_BASE):
|
||||
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
|
||||
memo = Column(String(100), nullable=True) # 메모
|
||||
md_price = Column(BigInteger, nullable=True)
|
||||
supplier_type = Column(SmallInteger, nullable=True)
|
||||
iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수
|
||||
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
|
||||
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)
|
||||
|
||||
@ -105,10 +105,15 @@ async def anchor_seed(db_engine):
|
||||
{"iid": item_id, "name": f"앵커상품 {code}", "code": f"{MARK}{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, supplier_type) "
|
||||
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 1, 2, now(), now() + interval '2 hours', 1)"),
|
||||
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, 1, 2, now(), now() + interval '2 hours')"),
|
||||
{"qid": qt_id, "name": f"앵커견적 {code}", "num": f"{MARK}{code}"},
|
||||
)
|
||||
await conn.execute(
|
||||
text("INSERT INTO partner.supplier_items (supplier_item_id, supplier_id, item_id, supply_type) "
|
||||
"VALUES (gen_random_uuid(), :sid, :iid, 1)"),
|
||||
{"sid": supplier_id, "iid": item_id},
|
||||
)
|
||||
await conn.execute(
|
||||
text("INSERT INTO negotiation.sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||
|
||||
@ -25,21 +25,23 @@ _current_values = table(
|
||||
)
|
||||
|
||||
|
||||
async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_type: int) -> dict:
|
||||
"""칸별 현재 앵커링 값 일괄 조회. {(company_id, price_range_index): rate‰} 반환.
|
||||
async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_types: list[int]) -> dict:
|
||||
"""칸별 현재 앵커링 값 일괄 조회. {(company_id, supplier_type, price_range_index): rate‰} 반환.
|
||||
|
||||
supplier_type 은 견적 단위로 하나뿐이라 키에 넣지 않는다.
|
||||
supplier_type 은 세션의 (item_id, supplier_id) 매핑에서 온 supply_type 이다.
|
||||
조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 시 빈 dict."""
|
||||
if not company_ids or supplier_type not in SAMPLEABLE_SUPPLIER_TYPES:
|
||||
valid_types = sorted({int(t) for t in supplier_types if t in SAMPLEABLE_SUPPLIER_TYPES})
|
||||
if not company_ids or not valid_types:
|
||||
return {}
|
||||
try:
|
||||
stmt = select(
|
||||
_current_values.c.company_id,
|
||||
_current_values.c.supplier_type,
|
||||
_current_values.c.price_range_index,
|
||||
_current_values.c.anchoring_value,
|
||||
).where(
|
||||
_current_values.c.company_id.in_(company_ids),
|
||||
_current_values.c.supplier_type == supplier_type,
|
||||
_current_values.c.supplier_type.in_(valid_types),
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
except Exception as ex:
|
||||
@ -47,9 +49,9 @@ async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_typ
|
||||
return {}
|
||||
|
||||
out = {}
|
||||
for company_id, price_range_index, rate in rows:
|
||||
for company_id, supplier_type, price_range_index, rate in rows:
|
||||
if not ANCHORING_VALUE_MIN <= rate <= ANCHORING_VALUE_MAX: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백
|
||||
LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} bracket={price_range_index} rate={rate}")
|
||||
LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} type={supplier_type} bracket={price_range_index} rate={rate}")
|
||||
continue
|
||||
out[(company_id, price_range_index)] = rate
|
||||
out[(company_id, supplier_type, price_range_index)] = rate
|
||||
return out
|
||||
|
||||
9
negodata/backend/common/authz.py
Normal file
9
negodata/backend/common/authz.py
Normal file
@ -0,0 +1,9 @@
|
||||
from common.enums import UserRole
|
||||
|
||||
|
||||
def is_owner_or_admin(resource_user_id, user_id, role) -> bool:
|
||||
"""변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자(OWNER)면 True.
|
||||
|
||||
프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처.
|
||||
소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리)."""
|
||||
return str(resource_user_id) == str(user_id) or role == UserRole.OWNER.value
|
||||
@ -135,7 +135,7 @@ class supplier_items(MainTableMixin, MAIN_BASE):
|
||||
supplier_item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # suppliers.supplier_id
|
||||
item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # items.item_id
|
||||
# SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). quotations.supplier_type 와 값은 같으나 의미 단위가 (협력사,상품)이라 컬럼명은 supply_type.
|
||||
# SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판).
|
||||
supply_type = Column(SmallInteger, nullable=False, server_default=text("0"), default=0)
|
||||
|
||||
|
||||
@ -229,8 +229,6 @@ class quotations(MainTableMixin, MAIN_BASE):
|
||||
manager_contact_number = Column(String(20), nullable=True)
|
||||
memo = Column(String(100), nullable=True)
|
||||
md_price = Column(BigInteger, nullable=True)
|
||||
supplier_type = Column(SmallInteger, nullable=True)
|
||||
|
||||
iteration = Column(Integer, nullable=False, default=0)
|
||||
preferred_sp_yn = Column(Boolean, nullable=True)
|
||||
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
@ -237,8 +237,8 @@ class CardType(CodeEnum):
|
||||
|
||||
|
||||
class SupplierType(CodeEnum):
|
||||
"""quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
|
||||
없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응."""
|
||||
"""partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
|
||||
없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다."""
|
||||
|
||||
NONE = 0 # 없음(미지정)
|
||||
DISTRIBUTION = 1 # 유통
|
||||
|
||||
@ -5,6 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import users
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -32,13 +33,21 @@ class ICardCRUD(ABC):
|
||||
async def soft_delete(self, cdb: AsyncSession, model, pk_col, card_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
|
||||
class CardCRUD(ICardCRUD):
|
||||
async def search(
|
||||
self, cdb: AsyncSession, model, user_id, search: Optional[str], skip: int, limit: int
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
conditions = [model.deleted == False, model.user_id == user_id] # noqa: E712
|
||||
# 내 개인 카드 + 전체(공용, user_id NULL) 카드. 남의 개인 카드는 제외.
|
||||
conditions = [
|
||||
model.deleted == False, # noqa: E712
|
||||
or_(model.user_id == user_id, model.user_id.is_(None)),
|
||||
]
|
||||
if search:
|
||||
conditions.append(
|
||||
or_(
|
||||
@ -102,3 +111,18 @@ class CardCRUD(ICardCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
"""user_id 목록 → {user_id: name}. 카드 목록 '작성자(등록자)' 표기용(company.users 조인).
|
||||
공용(user_id NULL) 카드는 호출 전에 걸러 넘긴다 → 맵에 없으면 작성자 없음."""
|
||||
try:
|
||||
if not user_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items
|
||||
from common.database.model.models import items, users
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -45,6 +45,10 @@ class IItemCRUD(ABC):
|
||||
async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
|
||||
class ItemCRUD(IItemCRUD):
|
||||
async def search(
|
||||
@ -166,3 +170,17 @@ class ItemCRUD(IItemCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
"""user_id 목록 → {user_id: name}. 상품 목록/상세 '등록자(작성자)' 표기용(company.users 조인)."""
|
||||
try:
|
||||
if not user_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
@ -8,9 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import (
|
||||
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
|
||||
version_nego_cards, version_wild_cards, users,
|
||||
version_nego_cards, version_wild_cards, users, supplier_items,
|
||||
)
|
||||
from common.enums import ErrorType, QuotationStatus, SessionStatus
|
||||
from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
@ -44,7 +44,7 @@ class IQuotationCRUD(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]:
|
||||
async def get_supply_types(self, cdb: AsyncSession, item_ids, supplier_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -148,6 +148,10 @@ class IQuotationCRUD(ABC):
|
||||
async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]:
|
||||
pass
|
||||
|
||||
|
||||
class QuotationCRUD(IQuotationCRUD):
|
||||
async def search(
|
||||
@ -396,33 +400,30 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]:
|
||||
"""협력사의 직전 견적 supplier_type. (supplier_type, qt_number) | None.
|
||||
sessions(supplier_id) ⨝ quotations 에서 supplier_type 가 있는 최신 견적 1건."""
|
||||
async def get_supply_types(self, cdb: AsyncSession, item_ids, supplier_ids) -> Tuple[ErrorType, dict]:
|
||||
"""(item_id, supplier_id) -> supplier_items.supply_type 매핑.
|
||||
|
||||
매핑이 없거나 supply_type 이 0/NULL 이면 호출측이 정적 앵커링 값으로 폴백한다.
|
||||
"""
|
||||
try:
|
||||
conds = [
|
||||
sessions.supplier_id == supplier_id,
|
||||
quotations.supplier_type.isnot(None),
|
||||
quotations.deleted == False, # noqa: E712
|
||||
]
|
||||
if company_id is not None:
|
||||
conds.append(quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)))
|
||||
query = (
|
||||
select(quotations.supplier_type, quotations.number)
|
||||
.join(sessions, sessions.quotation_id == quotations.qt_id)
|
||||
.where(*conds)
|
||||
.order_by(quotations.created_at.desc())
|
||||
.limit(1)
|
||||
if not item_ids or not supplier_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = select(
|
||||
supplier_items.item_id,
|
||||
supplier_items.supplier_id,
|
||||
supplier_items.supply_type,
|
||||
).where(
|
||||
supplier_items.item_id.in_(item_ids),
|
||||
supplier_items.supplier_id.in_(supplier_ids),
|
||||
supplier_items.deleted == False, # noqa: E712
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
if not rows:
|
||||
return ErrorType.SUCCESS, None
|
||||
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
|
||||
return err_type, {}
|
||||
return ErrorType.SUCCESS, {(r[0], r[1]): r[2] for r in rows}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
||||
"""견적 세팅의 목표 마진율: {margin}. 목표가 산정 입력(인터넷 수수료는 상수).
|
||||
@ -474,6 +475,36 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]:
|
||||
"""[동시 직접낙찰 가드] 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)인 견적만 낙찰(AWARDED)로 선점 전이.
|
||||
선택 협력사를 낙찰자(preferred_sp_*)로 박고 동가 플래그는 내린다. status 는 이미 CLOSED 라 유지.
|
||||
반환: (ErrorType, 적용행수). 이미 낙찰됐거나(재클릭) 개찰이 아니면 0 → 서비스가 한 번만 통과시킨다."""
|
||||
try:
|
||||
query = (
|
||||
update(quotations)
|
||||
.where(
|
||||
quotations.qt_id == qt_id,
|
||||
quotations.status == QuotationStatus.CLOSED.value,
|
||||
quotations.close_reason.in_(
|
||||
[CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value,
|
||||
CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value]
|
||||
),
|
||||
quotations.deleted == False, # noqa: E712
|
||||
)
|
||||
.values(
|
||||
close_reason=CloseReason.AWARDED.value,
|
||||
preferred_sp_yn=True,
|
||||
preferred_sp_id=supplier_id,
|
||||
preferred_sp_name=supplier_name,
|
||||
equal_bid_yn=False,
|
||||
updated_at=GTime.UTC(),
|
||||
)
|
||||
)
|
||||
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
|
||||
# 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다.
|
||||
try:
|
||||
|
||||
214
negodata/backend/crud/statistics_crud.py
Normal file
214
negodata/backend/crud/statistics_crud.py
Normal file
@ -0,0 +1,214 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import select, func, and_, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations, sessions, items, chats, users
|
||||
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus
|
||||
from common.logger import LOG
|
||||
|
||||
|
||||
# 통계 유니버스 = 현재 마감사유 5코드로 마감된 견적. 레거시 REGEN_*(2·3·4) 은 제외해
|
||||
# KPI(낙찰률·마감수)와 유형별/결과분해의 분모를 일치시킨다(프로덕션엔 레거시 없어 전체 마감과 동일).
|
||||
CURRENT_CLOSE_REASONS = [
|
||||
CloseReason.AWARDED.value,
|
||||
CloseReason.OPEN_PRICE.value,
|
||||
CloseReason.OPEN_EQUAL.value,
|
||||
CloseReason.OPEN_NOSHOW.value,
|
||||
CloseReason.OPEN_REJECT.value,
|
||||
]
|
||||
|
||||
|
||||
# 통계 집계 CRUD. 대시보드와 동일하게 회사 스코프(작성자 user_id→users.company_id)로 건다.
|
||||
# owner(user_id) 가 주어지면 '내가 만든 견적'으로 더 좁힌다. quotations 엔 company_id 컬럼이 없어 서브쿼리로.
|
||||
def _company_scope(company_id, owner) -> list:
|
||||
conds = [
|
||||
quotations.deleted == False, # noqa: E712
|
||||
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
|
||||
]
|
||||
if owner is not None:
|
||||
conds.append(quotations.user_id == owner)
|
||||
return conds
|
||||
|
||||
|
||||
class IStatisticsCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def outcome_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def type_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def participation_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
|
||||
class StatisticsCRUD(IStatisticsCRUD):
|
||||
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 낙찰 마감 견적의 '낙찰 세션'(supplier_id=preferred_sp_id) 행 — 절감/추이/유형/카테고리/앵커도달률의 단일 원천.
|
||||
# 파생: 저장 안 하고 조회 때 조인. category 는 items LEFT JOIN(자유텍스트·NULL 허용).
|
||||
try:
|
||||
stmt = (
|
||||
select(
|
||||
quotations.updated_at,
|
||||
quotations.type,
|
||||
items.category,
|
||||
sessions.target_price,
|
||||
sessions.bid_price,
|
||||
sessions.anchoring_price,
|
||||
)
|
||||
.select_from(quotations)
|
||||
.join(
|
||||
sessions,
|
||||
and_(
|
||||
sessions.quotation_id == quotations.qt_id,
|
||||
sessions.supplier_id == quotations.preferred_sp_id,
|
||||
sessions.bid_price.isnot(None),
|
||||
sessions.deleted == False, # noqa: E712
|
||||
),
|
||||
)
|
||||
.join(items, items.item_id == sessions.item_id, isouter=True)
|
||||
.where(
|
||||
and_(
|
||||
*_company_scope(company_id, owner),
|
||||
quotations.status == QuotationStatus.CLOSED.value,
|
||||
quotations.close_reason == CloseReason.AWARDED.value,
|
||||
quotations.updated_at >= since,
|
||||
)
|
||||
)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def outcome_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 마감 결과 분해: close_reason 별 건수. 낙찰률·마감건수도 여기서 파생.
|
||||
try:
|
||||
stmt = (
|
||||
select(quotations.close_reason, func.count())
|
||||
.where(
|
||||
and_(
|
||||
*_company_scope(company_id, owner),
|
||||
quotations.status == QuotationStatus.CLOSED.value,
|
||||
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
|
||||
quotations.updated_at >= since,
|
||||
)
|
||||
)
|
||||
.group_by(quotations.close_reason)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def type_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 유형별(협상/경매) 마감 건수 + 낙찰 건수 → 유형별 낙찰률.
|
||||
try:
|
||||
awarded = func.sum(case((quotations.close_reason == CloseReason.AWARDED.value, 1), else_=0))
|
||||
stmt = (
|
||||
select(quotations.type, func.count(), awarded)
|
||||
.where(
|
||||
and_(
|
||||
*_company_scope(company_id, owner),
|
||||
quotations.status == QuotationStatus.CLOSED.value,
|
||||
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
|
||||
quotations.updated_at >= since,
|
||||
)
|
||||
)
|
||||
.group_by(quotations.type)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def participation_counts(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 협력사 참여: 회사 견적(창 내 생성)의 세션을 status 별 집계(응찰/미응찰/거부).
|
||||
try:
|
||||
conds = [
|
||||
sessions.deleted == False, # noqa: E712
|
||||
quotations.deleted == False, # noqa: E712
|
||||
quotations.created_at >= since,
|
||||
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
|
||||
]
|
||||
if owner is not None:
|
||||
conds.append(quotations.user_id == owner)
|
||||
stmt = (
|
||||
select(sessions.status, func.count())
|
||||
.select_from(sessions)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(and_(*conds))
|
||||
.group_by(sessions.status)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
|
||||
# 평균 재견적 라운드. TODO: 체인키 없어 avg(round) 단순버전 — 체인당 최대 라운드 정의는 root_qt_id 도입 후.
|
||||
try:
|
||||
stmt = select(func.avg(quotations.round)).where(
|
||||
and_(
|
||||
*_company_scope(company_id, owner),
|
||||
quotations.status == QuotationStatus.CLOSED.value,
|
||||
quotations.close_reason.in_(CURRENT_CLOSE_REASONS),
|
||||
quotations.updated_at >= since,
|
||||
)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
if err != ErrorType.SUCCESS:
|
||||
return err, 0.0
|
||||
# 단일컬럼 집계는 execute 가 스칼라 리스트를 반환한다(대시보드 _count 와 동일). rows[0] 이 곧 avg 값.
|
||||
val = rows[0] if rows else None
|
||||
return ErrorType.SUCCESS, float(val) if val is not None else 0.0
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0.0
|
||||
|
||||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
|
||||
try:
|
||||
conds = [
|
||||
chats.deleted == False, # noqa: E712
|
||||
chats.card_used_yn.is_(True),
|
||||
quotations.deleted == False, # noqa: E712
|
||||
quotations.created_at >= since,
|
||||
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
|
||||
]
|
||||
if owner is not None:
|
||||
conds.append(quotations.user_id == owner)
|
||||
stmt = (
|
||||
select(chats.card_type, func.count())
|
||||
.select_from(chats)
|
||||
.join(sessions, sessions.session_id == chats.session_id)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(and_(*conds))
|
||||
.group_by(chats.card_type)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import suppliers
|
||||
from common.database.model.models import suppliers, users
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -41,6 +41,10 @@ class ISupplierCRUD(ABC):
|
||||
async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
|
||||
class SupplierCRUD(ISupplierCRUD):
|
||||
async def search(
|
||||
@ -144,3 +148,17 @@ class SupplierCRUD(ISupplierCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
"""user_id 목록 → {user_id: name}. 협력사 목록/상세 '등록자(작성자)' 표기용(company.users 조인)."""
|
||||
try:
|
||||
if not user_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
@ -19,6 +19,7 @@ import router.v1.card.card
|
||||
import router.v1.quotation.quotation
|
||||
import router.v1.quotation_setting.quotation_setting
|
||||
import router.v1.dashboard.dashboard
|
||||
import router.v1.statistics.statistics
|
||||
import router.v1.notification.notification
|
||||
|
||||
API_SERVER_START_TIME = GTime.UTCStr()
|
||||
@ -74,4 +75,5 @@ app.include_router(router.v1.card.card.router)
|
||||
app.include_router(router.v1.quotation.quotation.router)
|
||||
app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
||||
app.include_router(router.v1.dashboard.dashboard.router)
|
||||
app.include_router(router.v1.statistics.statistics.router)
|
||||
app.include_router(router.v1.notification.notification.router)
|
||||
|
||||
@ -14,6 +14,7 @@ class CardProtocol(WebPacketProtocol):
|
||||
|
||||
class Req_CreateCard(CardProtocol):
|
||||
is_wildcard: bool = False
|
||||
is_shared: bool = False # True=전체(공용, user_id NULL 저장) / False=개인(등록 유저 소유)
|
||||
name: Optional[str] = None
|
||||
number: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
@ -42,6 +43,8 @@ class CardData(WebPacketProtocol):
|
||||
nego_card_id: uuid.UUID # 통합 식별자(일반=nego_card_id / 와일드=wild_card_id)
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
is_wildcard: bool = False
|
||||
is_shared: bool = False # 전체(공용) 카드 여부 = user_id NULL. 목록 배지/폼 스코프 표시용
|
||||
creator_name: Optional[str] = None # 작성자(등록자) 이름. user_id→company.users.name 조인. 공용 카드는 None
|
||||
name: Optional[str] = None
|
||||
number: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
|
||||
@ -69,12 +69,12 @@ async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: U
|
||||
async def update_item(
|
||||
item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req))
|
||||
return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.delete(path="/delete/{item_id}", response_model=Res_DeleteItem, summary="상품 삭제")
|
||||
async def delete_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id)))
|
||||
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id), user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)")
|
||||
|
||||
@ -64,6 +64,7 @@ class ItemData(WebPacketProtocol):
|
||||
item_id: uuid.UUID
|
||||
company_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인
|
||||
name: str
|
||||
code: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
@ -4,7 +4,7 @@ from typing import Any, Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus, SupplierType
|
||||
from common.enums import CardType, ChatSender, CloseReason, DeliveryType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
|
||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -26,7 +26,6 @@ class Req_CreateQuotation(QuotationProtocol):
|
||||
manager_contact_number: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
md_price: Optional[int] = None # MD 제시가(원). 세션 목표가 산정 최우선값
|
||||
supplier_type: Optional[int] = None # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
|
||||
item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
|
||||
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
|
||||
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결
|
||||
@ -40,6 +39,10 @@ class Req_RegenerateQuotation(QuotationProtocol):
|
||||
supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음
|
||||
|
||||
|
||||
class Req_AwardQuotation(QuotationProtocol):
|
||||
winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(투찰한 협상완료 세션 중 선택)
|
||||
|
||||
|
||||
class QuotationData(WebPacketProtocol):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@ -59,7 +62,6 @@ class QuotationData(WebPacketProtocol):
|
||||
manager_contact_number: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
md_price: Optional[int] = None
|
||||
supplier_type: Optional[SupplierType] = None
|
||||
iteration: int = 0
|
||||
preferred_sp_yn: Optional[bool] = None
|
||||
preferred_sp_id: Optional[uuid.UUID] = None
|
||||
@ -185,11 +187,6 @@ class Res_QuotationCards(Res_WebPacketProtocol):
|
||||
cards: list[QuotationCardData] = []
|
||||
|
||||
|
||||
class Res_LastSupplierType(Res_WebPacketProtocol):
|
||||
supplier_type: Optional[SupplierType] = None # 협력사 직전 견적의 유형(없으면 None)
|
||||
qt_number: Optional[str] = None # 그 견적의 번호(이전 견적 값임을 표시용)
|
||||
|
||||
|
||||
class TargetCandidate(WebPacketProtocol):
|
||||
basis: str
|
||||
label: str
|
||||
|
||||
@ -7,11 +7,11 @@ from common.models.gmodel import PageParams, UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.quotation_service import QuotationService
|
||||
from .protocol import (
|
||||
Req_AwardQuotation,
|
||||
Req_CreateQuotation,
|
||||
Req_RegenerateQuotation,
|
||||
Res_CreateQuotation,
|
||||
Res_DeleteQuotation,
|
||||
Res_LastSupplierType,
|
||||
Res_NotifySessions,
|
||||
Res_Quotation,
|
||||
Res_QuotationCards,
|
||||
@ -55,14 +55,24 @@ async def create_quotation(
|
||||
|
||||
@router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감")
|
||||
async def stop_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.post(path="/award/{qt_id}", response_model=Res_Quotation, summary="개찰 견적 직접 낙찰")
|
||||
async def award_quotation(
|
||||
qt_id: UUID, req: Req_AwardQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
# 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다.
|
||||
return RemoveNoneResponse(
|
||||
await service.award_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role, req.winner_supplier_id)
|
||||
)
|
||||
|
||||
|
||||
@router.post(path="/regenerate/{qt_id}", response_model=Res_CreateQuotation, summary="견적 재생성(다음 라운드)")
|
||||
async def regenerate_quotation(
|
||||
qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids))
|
||||
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
|
||||
@ -78,7 +88,7 @@ async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depend
|
||||
|
||||
@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송")
|
||||
async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세")
|
||||
@ -93,7 +103,7 @@ async def get_target_breakdown(session_id: UUID, service: QuotationService = Dep
|
||||
|
||||
@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송")
|
||||
async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과")
|
||||
@ -108,12 +118,7 @@ async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends()
|
||||
|
||||
@router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제")
|
||||
async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id))
|
||||
|
||||
|
||||
@router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형")
|
||||
async def get_supplier_last_type(supplier_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_last_supplier_type(str(supplier_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----
|
||||
|
||||
72
negodata/backend/router/v1/statistics/protocol.py
Normal file
72
negodata/backend/router/v1/statistics/protocol.py
Normal file
@ -0,0 +1,72 @@
|
||||
from pydantic import Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
class StatisticsProtocol(WebPacketProtocol):
|
||||
pass
|
||||
|
||||
|
||||
class StatKpi(WebPacketProtocol):
|
||||
total_savings: int = 0 # 총 절감액(목표가 대비, 낙찰세션 target−bid 합)
|
||||
savings_rate: float = 0.0 # 평균 절감률 = 절감합/목표합
|
||||
award_rate: float = 0.0 # 낙찰률 = 낙찰 마감 / 전체 마감
|
||||
anchor_reach_rate: float = 0.0 # 평균 앵커 도달률 = (목표−투찰)/(목표−앵커)
|
||||
savings_delta_mom: int = 0 # 전월 대비 절감액 증감
|
||||
closed_count: int = 0 # 마감 견적 수(창)
|
||||
regen_avg_round: float = 0.0 # 평균 재견적 라운드
|
||||
|
||||
|
||||
class StatMonthPoint(WebPacketProtocol):
|
||||
month: str # 'YYYY-MM'
|
||||
savings: int = 0
|
||||
rate: float = 0.0
|
||||
|
||||
|
||||
class StatOutcome(WebPacketProtocol):
|
||||
awarded: int = 0 # CloseReason 1
|
||||
open_price: int = 0 # 5 가격 미달
|
||||
open_equal: int = 0 # 6 동가
|
||||
open_noshow: int = 0 # 7 미응찰
|
||||
open_reject: int = 0 # 8 거부
|
||||
|
||||
|
||||
class StatParticipation(WebPacketProtocol):
|
||||
bid: int = 0 # 응찰(SessionStatus DONE)
|
||||
no_participate: int = 0 # 미응찰(NOT_PARTICIPATED)
|
||||
rejected: int = 0 # 거부(REJECTED)
|
||||
|
||||
|
||||
class StatTypeRow(WebPacketProtocol):
|
||||
label: str
|
||||
award_rate: float = 0.0
|
||||
avg_savings: int = 0
|
||||
count: int = 0
|
||||
|
||||
|
||||
class StatCategory(WebPacketProtocol):
|
||||
category: str
|
||||
savings: int = 0
|
||||
count: int = 0
|
||||
|
||||
|
||||
class StatCardUsage(WebPacketProtocol):
|
||||
type: str # 'nego' | 'wild'
|
||||
label: str
|
||||
uses: int = 0
|
||||
avg_drop: int = 0 # 사용 직후 상대 제시가 평균 하락. TODO: v1은 0(유형별 빈도만) — chats seq 델타 계산은 다음 단계.
|
||||
|
||||
|
||||
class StatScope(WebPacketProtocol):
|
||||
kpi: StatKpi = Field(default_factory=StatKpi)
|
||||
trend: list[StatMonthPoint] = []
|
||||
outcome: StatOutcome = Field(default_factory=StatOutcome)
|
||||
participation: StatParticipation = Field(default_factory=StatParticipation)
|
||||
type_split: list[StatTypeRow] = []
|
||||
categories: list[StatCategory] = []
|
||||
cards: list[StatCardUsage] = []
|
||||
|
||||
|
||||
class Res_StatisticsSummary(Res_WebPacketProtocol):
|
||||
company: StatScope = Field(default_factory=StatScope) # 회사 전체(company_id 스코프)
|
||||
mine: StatScope = Field(default_factory=StatScope) # 내가 만든 견적(user_id 추가 스코프)
|
||||
14
negodata/backend/router/v1/statistics/statistics.py
Normal file
14
negodata/backend/router/v1/statistics/statistics.py
Normal file
@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.statistics_service import StatisticsService
|
||||
from .protocol import Res_StatisticsSummary
|
||||
|
||||
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))의 UserInfo 로 company/user 스코프 집계를 한 번에 내린다.
|
||||
router = APIRouter(prefix="/v1/statistics", tags=["Statistics"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(path="/summary", response_model=Res_StatisticsSummary, summary="통계 요약(회사 전체 + 내 견적)")
|
||||
async def get_statistics_summary(service: StatisticsService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_summary(user_info.company_id, user_info.user_id))
|
||||
@ -35,6 +35,7 @@ class SupplierData(WebPacketProtocol):
|
||||
supplier_id: uuid.UUID
|
||||
company_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인
|
||||
name: str
|
||||
code: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
|
||||
@ -3,7 +3,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from common.models.gmodel import PageParams, UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner
|
||||
from services.supplier_service import SupplierService
|
||||
from .protocol import (
|
||||
Req_CheckCodes,
|
||||
@ -59,6 +59,7 @@ async def update_supplier(
|
||||
)
|
||||
|
||||
|
||||
@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제")
|
||||
async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제 (최고관리자 전용)")
|
||||
async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(RequireOwner)):
|
||||
# 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(RequireOwner 가 비-OWNER 를 403 차단).
|
||||
return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id)))
|
||||
|
||||
@ -34,7 +34,7 @@ INSERT INTO quotation.quotations
|
||||
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status,
|
||||
start_time, end_time, manager_name, manager_email, manager_contact_number, memo,
|
||||
iteration, preferred_sp_yn, preferred_sp_id, preferred_sp_name, equal_bid_yn, equal_bid_data,
|
||||
close_reason, md_price, supplier_type, created_at, updated_at, deleted)
|
||||
close_reason, md_price, created_at, updated_at, deleted)
|
||||
VALUES
|
||||
-- ① 낙찰(절감): 목표 400,000 → 낙찰 372,000
|
||||
('aaaa0001-0000-0000-0000-000000000001','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
@ -42,14 +42,14 @@ VALUES
|
||||
'① 낙찰(절감) 데모','EST-DEMO-01',4,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰·목표대비 절감 케이스',
|
||||
1, true,'9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09','(주)대한정밀', null, null,
|
||||
1, 400000, 1, now(), now(), false),
|
||||
1, 400000, now(), now(), false),
|
||||
-- ② 낙찰(목표초과): 목표 350,000 → 낙찰 368,000 (over_action=낙찰)
|
||||
('aaaa0002-0000-0000-0000-000000000002','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'② 낙찰(목표초과) 데모','EST-DEMO-02',4,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','낙찰이지만 목표가 초과 케이스',
|
||||
1, true,'69e7057a-5919-48cd-b329-13b833bff994','서울산업소재(주)', null, null,
|
||||
1, 350000, 1, now(), now(), false),
|
||||
1, 350000, now(), now(), false),
|
||||
-- ③ 동가 재입찰(REGEN_EQUAL): 두 곳 390,000 동률 → 재입찰 진행
|
||||
('aaaa0003-0000-0000-0000-000000000003','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
@ -57,42 +57,42 @@ VALUES
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가 발생·다음 라운드 재입찰 케이스',
|
||||
1, false, null, null, true,
|
||||
'[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":390000},{"supplier_id":"747897ec-adab-4f3e-9076-15803ddf821f","supplier_name":"글로벌테크놀로지","bid_price":390000}]'::jsonb,
|
||||
6, 400000, 1, now(), now(), false),
|
||||
6, 400000, now(), now(), false),
|
||||
-- ④ 거부 유찰(FAIL_REJECT): 협상거부로 마감
|
||||
('aaaa0004-0000-0000-0000-000000000004','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'④ 거부 개찰 데모','EST-DEMO-04',2,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','협상 거부로 유찰 케이스',
|
||||
1, null, null, null, null, null,
|
||||
8, 400000, 1, now(), now(), false),
|
||||
8, 400000, now(), now(), false),
|
||||
-- ⑤ 미참여 재소집(REGEN_NOSHOW): 전원 미참여 → 다음 라운드 재소집
|
||||
('aaaa0005-0000-0000-0000-000000000005','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑤ 미응찰 개찰 데모','EST-DEMO-05',1,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여·다음 라운드 재소집 케이스',
|
||||
1, false, null, null, false, null,
|
||||
7, 400000, 1, now(), now(), false),
|
||||
7, 400000, now(), now(), false),
|
||||
-- ⑥ 진행중: 일부 투찰 완료, 아직 안 닫힘 (close_reason NULL)
|
||||
('aaaa0006-0000-0000-0000-000000000006','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑥ 진행중 데모','EST-DEMO-06',4,1,2,
|
||||
now()-interval '1 day', now()+interval '1 day','김담당','mgr@imk.kr','010-1111-1111','협상 진행중·현재 최저 투찰 케이스',
|
||||
1, null, null, null, null, null,
|
||||
null, 400000, 1, now(), now(), false),
|
||||
null, 400000, now(), now(), false),
|
||||
-- ⑦ 가격 재협상(REGEN_PRICE): 단독 최저가 420,000 > 목표 350,000 → 재협상 진행
|
||||
('aaaa0007-0000-0000-0000-000000000007','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑦ 목표초과 개찰 데모','EST-DEMO-07',4,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 재협상(더 깎기) 진행 케이스',
|
||||
1, false, null, null, false, null,
|
||||
5, 350000, 1, now(), now(), false),
|
||||
5, 350000, now(), now(), false),
|
||||
-- ⑧ 가격 유찰(FAIL_PRICE): 단독 최저가 430,000 > 목표 350,000, over_action=유찰(또는 한도소진) → 유찰
|
||||
('aaaa0008-0000-0000-0000-000000000008','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑧ 목표초과 개찰 데모(2)','EST-DEMO-08',4,1,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','목표 초과 → 유찰 케이스',
|
||||
1, false, null, null, false, null,
|
||||
5, 350000, 1, now(), now(), false),
|
||||
5, 350000, now(), now(), false),
|
||||
-- ⑨ 동가 유찰(FAIL_EQUAL): 동가지만 재입찰 한도 소진/유찰 정책 → 유찰
|
||||
('aaaa0009-0000-0000-0000-000000000009','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
@ -100,14 +100,14 @@ VALUES
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','동가지만 재입찰 한도 소진 → 유찰 케이스',
|
||||
1, false, null, null, true,
|
||||
'[{"supplier_id":"9795c2e5-3ac2-4e44-aeb5-c6c5e3053f09","supplier_name":"(주)대한정밀","bid_price":405000},{"supplier_id":"69e7057a-5919-48cd-b329-13b833bff994","supplier_name":"서울산업소재(주)","bid_price":405000}]'::jsonb,
|
||||
6, 400000, 1, now(), now(), false),
|
||||
6, 400000, now(), now(), false),
|
||||
-- ⑩ 미참여 유찰(FAIL_NOSHOW): 전원 미참여 + 재소집 한도 소진 → 유찰
|
||||
('aaaa0010-0000-0000-0000-000000000010','b874f33d-afc3-4498-a9c2-19bc5ae8faba',
|
||||
'40614764-43d5-4c45-a2f7-ccf659884c37','00000000-0000-0000-0000-000000000030',
|
||||
'⑩ 미응찰 개찰 데모(2)','EST-DEMO-10',1,2,3,
|
||||
now()-interval '2 days', now()-interval '1 hour','김담당','mgr@imk.kr','010-1111-1111','전원 미참여 + 재소집 한도 소진 → 유찰 케이스',
|
||||
1, false, null, null, false, null,
|
||||
7, 400000, 1, now(), now(), false);
|
||||
7, 400000, now(), now(), false);
|
||||
|
||||
-- ── 세션 ────────────────────────────────────────────────────────────────
|
||||
-- 공통: item=a3d1fff5, DeliveryType 협력사배송=1
|
||||
|
||||
@ -25,6 +25,7 @@ class CardService:
|
||||
nego_card_id=row.nego_card_id,
|
||||
user_id=row.user_id,
|
||||
is_wildcard=False,
|
||||
is_shared=row.user_id is None,
|
||||
name=row.name,
|
||||
number=row.number,
|
||||
script=row.script,
|
||||
@ -42,6 +43,7 @@ class CardService:
|
||||
nego_card_id=row.wild_card_id,
|
||||
user_id=row.user_id,
|
||||
is_wildcard=True,
|
||||
is_shared=row.user_id is None,
|
||||
name=row.name,
|
||||
number=row.number,
|
||||
script=row.script,
|
||||
@ -56,7 +58,8 @@ class CardService:
|
||||
|
||||
# ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------
|
||||
async def _find_owned(self, user_uuid: uuid.UUID, card_id: uuid.UUID):
|
||||
"""card_id 를 nego_cards → wild_cards 순으로 찾고 소유권 확인.
|
||||
"""card_id 를 nego_cards → wild_cards 순으로 찾고 접근권 확인.
|
||||
전체(공용, user_id NULL) 카드는 누구나 조회·수정·삭제 가능. 개인 카드는 소유자만.
|
||||
(ErrorType, model, pk_col, row, is_wildcard) 반환."""
|
||||
err, row = await DB_SESSION_MNG.execute_lambda(
|
||||
nego_cards.DBType(),
|
||||
@ -64,7 +67,7 @@ class CardService:
|
||||
lambda s: self.card_crud.get_by_id(s, nego_cards, nego_cards.nego_card_id, card_id),
|
||||
)
|
||||
if err == ErrorType.SUCCESS and row is not None:
|
||||
if row.user_id != user_uuid:
|
||||
if row.user_id is not None and row.user_id != user_uuid:
|
||||
return ErrorType.CARD_NOT_FOUND, None, None, None, False
|
||||
return ErrorType.SUCCESS, nego_cards, nego_cards.nego_card_id, row, False
|
||||
|
||||
@ -74,7 +77,7 @@ class CardService:
|
||||
lambda s: self.card_crud.get_by_id(s, wild_cards, wild_cards.wild_card_id, card_id),
|
||||
)
|
||||
if err == ErrorType.SUCCESS and row is not None:
|
||||
if row.user_id != user_uuid:
|
||||
if row.user_id is not None and row.user_id != user_uuid:
|
||||
return ErrorType.CARD_NOT_FOUND, None, None, None, True
|
||||
return ErrorType.SUCCESS, wild_cards, wild_cards.wild_card_id, row, True
|
||||
|
||||
@ -114,7 +117,21 @@ class CardService:
|
||||
|
||||
merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows]
|
||||
merged.sort(key=lambda c: c.created_at or "", reverse=True)
|
||||
res.cards = merged[pg.skip : pg.skip + pg.size]
|
||||
page = merged[pg.skip : pg.skip + pg.size]
|
||||
|
||||
# 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑.
|
||||
# (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음.
|
||||
author_ids = list({c.user_id for c in page if c.user_id is not None})
|
||||
if author_ids:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
nego_cards.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.card_crud.user_name_map(s, author_ids),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
for c in page:
|
||||
c.creator_name = name_map.get(c.user_id)
|
||||
res.cards = page
|
||||
res.total_nego = total_n
|
||||
res.total_wild = total_w
|
||||
# 선택된 탭 기준 페이지네이션 총건수(전체=합산).
|
||||
@ -134,6 +151,14 @@ class CardService:
|
||||
res.result.SetResult(err)
|
||||
return res
|
||||
res.card = self._wild_to_data(row) if is_wild else self._nego_to_data(row)
|
||||
# 등록자명 — 공용(user_id NULL) 카드는 작성자 없음(None 유지).
|
||||
if row.user_id is not None:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
nego_cards.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.card_crud.user_name_map(s, [row.user_id]),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
res.card.creator_name = name_map.get(row.user_id)
|
||||
return res
|
||||
|
||||
# ---- 등록 ----------------------------------------------------------------
|
||||
@ -141,9 +166,11 @@ class CardService:
|
||||
res = Res_Card()
|
||||
user_uuid = uuid.UUID(user_id)
|
||||
is_wildcard = req.is_wildcard
|
||||
# 전체(공용) 카드는 소유자 없이 저장(user_id NULL) → 모든 유저 목록에 노출.
|
||||
owner_id = None if req.is_shared else user_uuid
|
||||
|
||||
common = dict(
|
||||
user_id=user_uuid,
|
||||
user_id=owner_id,
|
||||
name=req.name,
|
||||
number=req.number,
|
||||
script=req.script,
|
||||
|
||||
@ -2,6 +2,7 @@ import uuid
|
||||
|
||||
from fastapi import Depends, UploadFile
|
||||
|
||||
from common.authz import is_owner_or_admin
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import items
|
||||
from common.enums import DBWRType, ErrorType
|
||||
@ -56,6 +57,16 @@ class ItemService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.items = [ItemData.model_validate(r) for r in rows]
|
||||
# 등록자명 배치 조인 — 페이지 상품의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님).
|
||||
author_ids = list({r.user_id for r in rows if r.user_id is not None})
|
||||
if author_ids:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.item_crud.user_name_map(s, author_ids),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
for d in res.items:
|
||||
d.creator_name = name_map.get(d.user_id)
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
@ -81,6 +92,13 @@ class ItemService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.item = ItemData.model_validate(item)
|
||||
if item.user_id is not None:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.item_crud.user_name_map(s, [item.user_id]),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
res.item.creator_name = name_map.get(item.user_id)
|
||||
return res
|
||||
|
||||
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:
|
||||
@ -128,17 +146,22 @@ class ItemService:
|
||||
# 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다.
|
||||
return await self.get_item(company_id, str(item.item_id))
|
||||
|
||||
async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem) -> Res_Item:
|
||||
async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem, user_id=None, role=None) -> Res_Item:
|
||||
res = Res_Item()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
item_uuid = uuid.UUID(item_id)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
|
||||
# 소유권 확인
|
||||
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
# 회사 스코프 확인
|
||||
err_type, item = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS or item is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 등록한 상품만 수정할 수 있습니다."
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[items.DBType()],
|
||||
@ -151,15 +174,20 @@ class ItemService:
|
||||
# 갱신 후 재조회
|
||||
return await self.get_item(company_id, item_id)
|
||||
|
||||
async def delete_item(self, company_id: str, item_id: str) -> Res_DeleteItem:
|
||||
async def delete_item(self, company_id: str, item_id: str, user_id=None, role=None) -> Res_DeleteItem:
|
||||
res = Res_DeleteItem()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
item_uuid = uuid.UUID(item_id)
|
||||
|
||||
err_type, _ = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
err_type, item = await self._fetch_owned(company_uuid, item_uuid)
|
||||
if err_type != ErrorType.SUCCESS or item is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 등록한 상품만 삭제할 수 있습니다."
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[items.DBType()],
|
||||
|
||||
@ -12,6 +12,7 @@ from common.anchoring import (
|
||||
fetch_current_values,
|
||||
get_base_anchoring_value,
|
||||
)
|
||||
from common.authz import is_owner_or_admin
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
|
||||
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
|
||||
@ -28,7 +29,6 @@ from router.v1.quotation.protocol import (
|
||||
Req_CreateQuotation,
|
||||
Res_CreateQuotation,
|
||||
Res_DeleteQuotation,
|
||||
Res_LastSupplierType,
|
||||
Res_NotifySessions,
|
||||
Res_Quotation,
|
||||
Res_QuotationCards,
|
||||
@ -254,22 +254,6 @@ class QuotationService:
|
||||
res.quotation = QuotationData.model_validate(quotation)
|
||||
return res
|
||||
|
||||
async def get_last_supplier_type(self, supplier_id: str, company_id=None) -> Res_LastSupplierType:
|
||||
"""협력사의 직전 견적 supplier_type(견적생성 모달 프리필용). 이력 없으면 비워서 반환."""
|
||||
res = Res_LastSupplierType()
|
||||
err_type, got = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_last_supplier_type(s, uuid.UUID(supplier_id), company_id),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
if got:
|
||||
res.supplier_type = got[0]
|
||||
res.qt_number = got[1]
|
||||
return res
|
||||
|
||||
async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
|
||||
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다.
|
||||
생성 성공 시 작성자에게 CREATED 알림(인박스)."""
|
||||
@ -290,7 +274,6 @@ class QuotationService:
|
||||
manager_contact_number=req.manager_contact_number,
|
||||
memo=req.memo,
|
||||
md_price=req.md_price,
|
||||
supplier_type=req.supplier_type,
|
||||
item_ids=req.item_ids,
|
||||
supplier_ids=req.supplier_ids,
|
||||
card_ids=req.card_ids,
|
||||
@ -369,7 +352,6 @@ class QuotationService:
|
||||
manager_contact_number=original.manager_contact_number,
|
||||
memo=original.memo,
|
||||
md_price=original.md_price,
|
||||
supplier_type=original.supplier_type,
|
||||
item_ids=item_ids,
|
||||
supplier_ids=list(supplier_ids),
|
||||
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
|
||||
@ -382,7 +364,7 @@ class QuotationService:
|
||||
self, *,
|
||||
user_id: str, qt_setting_id, version_id, name: str, number: str,
|
||||
type_: int, status: int, round_: int, start_time, end_time,
|
||||
manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type,
|
||||
manager_name, manager_email, manager_contact_number, memo, md_price,
|
||||
item_ids: list, supplier_ids: list, card_ids: list,
|
||||
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
|
||||
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
|
||||
@ -417,7 +399,7 @@ class QuotationService:
|
||||
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
|
||||
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
|
||||
# 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) —
|
||||
# 칸(회사×협력사유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②.
|
||||
# 칸(회사×상품-협력사 공급유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②.
|
||||
|
||||
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
||||
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
|
||||
@ -464,7 +446,6 @@ class QuotationService:
|
||||
manager_contact_number=manager_contact_number,
|
||||
memo=memo,
|
||||
md_price=md_price,
|
||||
supplier_type=supplier_type,
|
||||
mid_action=mid_action,
|
||||
over_action=over_action,
|
||||
)
|
||||
@ -490,8 +471,8 @@ class QuotationService:
|
||||
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
|
||||
return res
|
||||
|
||||
# ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) anchoring_value 조회 후
|
||||
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는
|
||||
# ② 앵커가 산출 — 칸(items.company_id × supplier_items.supply_type × 목표가 구간) anchoring_value 조회 후
|
||||
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 매핑 미지정/조정 이력 없음/조회 실패는
|
||||
# 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
|
||||
_err, item_companies = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
@ -499,12 +480,19 @@ class QuotationService:
|
||||
lambda s: self.quotation_crud.get_item_companies(s, item_ids),
|
||||
)
|
||||
item_companies = item_companies if _err == ErrorType.SUCCESS else {}
|
||||
_err, supply_types = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_supply_types(s, item_ids, supplier_ids),
|
||||
)
|
||||
supply_types = supply_types if _err == ErrorType.SUCCESS else {}
|
||||
value_map = {}
|
||||
if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies:
|
||||
sampleable_supply_types = sorted({t for t in supply_types.values() if t in SAMPLEABLE_SUPPLIER_TYPES})
|
||||
if sampleable_supply_types and item_companies:
|
||||
value_map = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: fetch_current_values(s, list(set(item_companies.values())), supplier_type),
|
||||
lambda s: fetch_current_values(s, list(set(item_companies.values())), sampleable_supply_types),
|
||||
)
|
||||
|
||||
session_objs = []
|
||||
@ -512,11 +500,12 @@ class QuotationService:
|
||||
tp = target_prices[iid]
|
||||
price_range = calc_price_range_index(tp)
|
||||
company = item_companies.get(iid)
|
||||
value = value_map.get((company, price_range)) if company is not None else None
|
||||
for sid in supplier_ids:
|
||||
supply_type = supply_types.get((iid, sid))
|
||||
value = value_map.get((company, supply_type, price_range)) if company is not None else None
|
||||
if value is None:
|
||||
value = get_base_anchoring_value(price_range)
|
||||
ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성)
|
||||
for sid in supplier_ids:
|
||||
session_objs.append(
|
||||
sessions(
|
||||
session_id=uuid.uuid4(),
|
||||
@ -678,7 +667,7 @@ class QuotationService:
|
||||
# 4) 전원 미응찰 → 개찰(미응찰).
|
||||
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show")
|
||||
|
||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
|
||||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None) -> Res_CreateQuotation:
|
||||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||||
크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다.
|
||||
상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
|
||||
@ -689,6 +678,11 @@ class QuotationService:
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 재생성(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 재생성할 수 있습니다."
|
||||
return res
|
||||
# 마감된 견적만 재생성(진행 중인 라운드를 또 찍어 같은 번호가 동시에 살아있는 걸 막는다).
|
||||
if original.status != QuotationStatus.CLOSED.value:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
@ -711,29 +705,106 @@ class QuotationService:
|
||||
|
||||
return await self.regenerate_next_round(qt_uuid, supplier_ids)
|
||||
|
||||
async def stop_quotation(self, qt_id: str, company_id=None) -> Res_Quotation:
|
||||
async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
|
||||
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
|
||||
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감)."""
|
||||
res = Res_Quotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
# 존재 확인(+회사 가드)
|
||||
err_type, _ = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
err_type, original = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 마감(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 마감할 수 있습니다."
|
||||
return res
|
||||
|
||||
await self.close_and_decide(qt_uuid)
|
||||
return await self.get_quotation(qt_id, company_id)
|
||||
|
||||
async def delete_quotation(self, qt_id: str, company_id=None) -> Res_DeleteQuotation:
|
||||
async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation:
|
||||
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
|
||||
투찰한 협상완료(DONE) 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰).
|
||||
자동 낙찰(close_and_decide)과 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
|
||||
권한: 본인이 생성한 견적만. 단 최고관리자(OWNER)는 회사 내 남의 견적도 낙찰할 수 있다."""
|
||||
res = Res_Quotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id))
|
||||
|
||||
# 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND.
|
||||
err_type, original = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||||
return res
|
||||
|
||||
# 자기 견적만 직접 낙찰 — 최고관리자(OWNER)만 회사 내 남의 견적도 허용. 되돌릴 수 없는 낙찰이라 백엔드에서 강제한다.
|
||||
if not is_owner_or_admin(original.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 낙찰할 수 있습니다."
|
||||
return res
|
||||
|
||||
# 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)만 직접 낙찰 대상. 진행중/이미 낙찰은 거부.
|
||||
if original.status != QuotationStatus.CLOSED.value or original.close_reason not in (
|
||||
CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value,
|
||||
CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value,
|
||||
):
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
|
||||
return res
|
||||
|
||||
# 낙찰 후보 = 투찰한 협상완료(DONE) 세션. close_and_decide 와 같은 조회(list_sessions_status,
|
||||
# 공급사 삭제돼도 포함되는 outerjoin)를 써서 자동낙찰과 후보 집합을 일치시킨다.
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
|
||||
)
|
||||
rows = rows if err_type == ErrorType.SUCCESS else []
|
||||
winner = next(
|
||||
(r for r in rows
|
||||
if r.status == SessionStatus.DONE.value and r.bid_price is not None and r.supplier_id == sp_uuid),
|
||||
None,
|
||||
)
|
||||
if winner is None:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
res.msg = "선택한 협력사는 이 견적의 낙찰 후보(투찰한 협상완료 협력사)가 아닙니다."
|
||||
return res
|
||||
|
||||
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
|
||||
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
quotations.DBType(),
|
||||
lambda s: self.quotation_crud.claim_for_award(s, qt_uuid, sp_uuid, (winner.name or "")[:20]),
|
||||
)
|
||||
if claim_err != ErrorType.SUCCESS or claimed == 0:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
res.msg = "이미 낙찰 처리된 견적입니다."
|
||||
return res
|
||||
|
||||
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
|
||||
await create_notification(
|
||||
original.user_id, NotificationType.SUCCESS,
|
||||
{"qt_name": original.name, "qt_number": original.number,
|
||||
"winner_name": winner.name, "winner_price": winner.bid_price, "manual": True},
|
||||
ref_qt_id=qt_uuid,
|
||||
)
|
||||
return await self.get_quotation(qt_id, company_id)
|
||||
|
||||
async def delete_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_DeleteQuotation:
|
||||
res = Res_DeleteQuotation()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
|
||||
err_type, _ = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
err_type, original = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS or original is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 삭제할 수 있습니다."
|
||||
return res
|
||||
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[quotations.DBType()],
|
||||
@ -815,15 +886,20 @@ class QuotationService:
|
||||
return res
|
||||
|
||||
# ----- 협상 초청 메일 (수동 발송)
|
||||
async def notify_sessions(self, qt_id: str, company_id=None) -> Res_NotifySessions:
|
||||
async def notify_sessions(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions:
|
||||
"""[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다.
|
||||
대상 = email_sent_at IS NULL + 담당자 이메일 보유."""
|
||||
res = Res_NotifySessions()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
err_type, quotation = await self._fetch(qt_uuid, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 초청메일 발송(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다."
|
||||
return res
|
||||
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
@ -851,7 +927,7 @@ class QuotationService:
|
||||
await self._mark_emailed(sent_ids)
|
||||
return res
|
||||
|
||||
async def notify_session(self, session_id: str, company_id=None) -> Res_NotifySessions:
|
||||
async def notify_session(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions:
|
||||
"""[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송)."""
|
||||
res = Res_NotifySessions()
|
||||
sess_uuid = uuid.UUID(session_id)
|
||||
@ -866,9 +942,14 @@ class QuotationService:
|
||||
sess, sp_name, email = got[0], got[1], got[2]
|
||||
res.total = 1
|
||||
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 재발송(user_id 미지정=내부 호출은 스킵).
|
||||
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다."
|
||||
return res
|
||||
if not email:
|
||||
res.skipped = 1
|
||||
return res
|
||||
|
||||
193
negodata/backend/services/statistics_service.py
Normal file
193
negodata/backend/services/statistics_service.py
Normal file
@ -0,0 +1,193 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations
|
||||
from common.enums import DBWRType, ErrorType, QuotationType, SessionStatus, CardType, CloseReason
|
||||
from common.utils.gtime import GTime
|
||||
from crud.statistics_crud import StatisticsCRUD, IStatisticsCRUD
|
||||
from router.v1.statistics.protocol import (
|
||||
Res_StatisticsSummary,
|
||||
StatScope,
|
||||
StatKpi,
|
||||
StatMonthPoint,
|
||||
StatOutcome,
|
||||
StatParticipation,
|
||||
StatTypeRow,
|
||||
StatCategory,
|
||||
StatCardUsage,
|
||||
)
|
||||
|
||||
WINDOW_MONTHS = 6 # 최근 6개월(당월 포함) 창
|
||||
|
||||
|
||||
class StatisticsService:
|
||||
"""통계(성과 분석) 집계. 회사 전체(company)와 내 견적(mine) 두 스코프를 한 응답으로 내린다.
|
||||
|
||||
전부 파생(저장 안 함) — 조회 때 sessions/items/chats 조인 집계한다. 읽기 전용.
|
||||
절감 원천은 '낙찰 세션'(preferred_sp_id) 이며, 여기서 총절감·추이·유형·카테고리·앵커도달률을 모두 파생한다.
|
||||
"""
|
||||
|
||||
def __init__(self, stat_crud: IStatisticsCRUD = Depends(StatisticsCRUD)):
|
||||
self.stat_crud = stat_crud
|
||||
|
||||
async def get_summary(self, company_id: str, user_id: str) -> Res_StatisticsSummary:
|
||||
res = Res_StatisticsSummary()
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
user_uuid = uuid.UUID(user_id)
|
||||
labels, window_start = self._window(GTime.UTC())
|
||||
|
||||
res.company = await self._scope(company_uuid, None, labels, window_start)
|
||||
res.mine = await self._scope(company_uuid, user_uuid, labels, window_start)
|
||||
return res
|
||||
|
||||
# ── 스코프 집계 ─────────────────────────────────────────────
|
||||
async def _scope(self, company_uuid, owner_uuid, labels, since) -> StatScope:
|
||||
scope = StatScope()
|
||||
|
||||
win_rows = await self._read(lambda s: self.stat_crud.winning_sessions(s, company_uuid, owner_uuid, since))
|
||||
outcome_rows = await self._read(lambda s: self.stat_crud.outcome_counts(s, company_uuid, owner_uuid, since))
|
||||
type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since))
|
||||
part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since))
|
||||
regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since))
|
||||
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
|
||||
|
||||
scope.trend = self._trend(win_rows, labels)
|
||||
scope.categories = self._categories(win_rows)
|
||||
scope.outcome = self._outcome(outcome_rows)
|
||||
scope.participation = self._participation(part_rows)
|
||||
scope.type_split = self._type_split(type_rows, win_rows)
|
||||
scope.cards = self._cards(card_rows)
|
||||
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen)
|
||||
return scope
|
||||
|
||||
# ── 파생 계산 ───────────────────────────────────────────────
|
||||
def _kpi(self, win_rows, trend, outcome, regen) -> StatKpi:
|
||||
k = StatKpi()
|
||||
total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows)
|
||||
total_target = sum(int(r.target_price) for r in win_rows)
|
||||
k.total_savings = total_saving
|
||||
k.savings_rate = (total_saving / total_target) if total_target else 0.0
|
||||
k.anchor_reach_rate = self._anchor_reach(win_rows)
|
||||
|
||||
closed = outcome.awarded + outcome.open_price + outcome.open_equal + outcome.open_noshow + outcome.open_reject
|
||||
k.closed_count = closed
|
||||
k.award_rate = (outcome.awarded / closed) if closed else 0.0
|
||||
k.regen_avg_round = round(regen, 2)
|
||||
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
|
||||
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
|
||||
return k
|
||||
|
||||
def _anchor_reach(self, win_rows) -> float:
|
||||
# (목표−투찰)/(목표−앵커), 앵커 있고 목표>앵커인 세션만 평균.
|
||||
# 세션별로 [0,100%] 클램프 후 평균 — '도달률'이라 앵커 도달=100% 상한(앵커 뚫어도 100%로).
|
||||
# (앵커 gap 이 작으면 원비율이 100% 훌쩍 넘어 평균이 왜곡되므로 캡한다.)
|
||||
vals = []
|
||||
for r in win_rows:
|
||||
if r.anchoring_price is None:
|
||||
continue
|
||||
target, bid, anchor = int(r.target_price), int(r.bid_price), int(r.anchoring_price)
|
||||
span = target - anchor
|
||||
if span > 0:
|
||||
reach = (target - bid) / span
|
||||
vals.append(min(1.0, max(0.0, reach)))
|
||||
return (sum(vals) / len(vals)) if vals else 0.0
|
||||
|
||||
def _trend(self, win_rows, labels) -> list:
|
||||
bucket = {m: {"savings": 0, "target": 0} for m in labels}
|
||||
for r in win_rows:
|
||||
m = f"{r.updated_at.year:04d}-{r.updated_at.month:02d}"
|
||||
if m in bucket:
|
||||
bucket[m]["savings"] += int(r.target_price) - int(r.bid_price)
|
||||
bucket[m]["target"] += int(r.target_price)
|
||||
out = []
|
||||
for m in labels:
|
||||
b = bucket[m]
|
||||
rate = (b["savings"] / b["target"]) if b["target"] else 0.0
|
||||
out.append(StatMonthPoint(month=m, savings=b["savings"], rate=rate))
|
||||
return out
|
||||
|
||||
def _categories(self, win_rows) -> list:
|
||||
# TODO: items.category 자유텍스트 그룹 — 표기 흔들리면 지저분. 카테고리 정규화(코드/테이블) 후 개선.
|
||||
agg: dict = {}
|
||||
for r in win_rows:
|
||||
key = r.category or "미분류"
|
||||
a = agg.setdefault(key, {"savings": 0, "count": 0})
|
||||
a["savings"] += int(r.target_price) - int(r.bid_price)
|
||||
a["count"] += 1
|
||||
rows = [StatCategory(category=k, savings=v["savings"], count=v["count"]) for k, v in agg.items()]
|
||||
rows.sort(key=lambda x: x.savings, reverse=True)
|
||||
return rows
|
||||
|
||||
def _outcome(self, outcome_rows) -> StatOutcome:
|
||||
by = {int(cr): int(n) for cr, n in outcome_rows if cr is not None}
|
||||
return StatOutcome(
|
||||
awarded=by.get(CloseReason.AWARDED.value, 0),
|
||||
open_price=by.get(CloseReason.OPEN_PRICE.value, 0),
|
||||
open_equal=by.get(CloseReason.OPEN_EQUAL.value, 0),
|
||||
open_noshow=by.get(CloseReason.OPEN_NOSHOW.value, 0),
|
||||
open_reject=by.get(CloseReason.OPEN_REJECT.value, 0),
|
||||
)
|
||||
|
||||
def _participation(self, part_rows) -> StatParticipation:
|
||||
by = {int(st): int(n) for st, n in part_rows if st is not None}
|
||||
return StatParticipation(
|
||||
bid=by.get(SessionStatus.DONE.value, 0),
|
||||
no_participate=by.get(SessionStatus.NOT_PARTICIPATED.value, 0),
|
||||
rejected=by.get(SessionStatus.REJECTED.value, 0),
|
||||
)
|
||||
|
||||
def _type_split(self, type_rows, win_rows) -> list:
|
||||
# 4개 코드(협상 1·3 / 견적 2·4=1:N)를 2그룹으로 묶는다. 낙찰률·건수=type_counts, 평균절감=낙찰세션.
|
||||
grp = {"nego": {"count": 0, "awarded": 0}, "auction": {"count": 0, "awarded": 0}}
|
||||
for t, cnt, awarded in type_rows:
|
||||
g = "auction" if QuotationType.is_auction(int(t)) else "nego"
|
||||
grp[g]["count"] += int(cnt or 0)
|
||||
grp[g]["awarded"] += int(awarded or 0)
|
||||
|
||||
sav = {"nego": [], "auction": []}
|
||||
for r in win_rows:
|
||||
g = "auction" if QuotationType.is_auction(int(r.type)) else "nego"
|
||||
sav[g].append(int(r.target_price) - int(r.bid_price))
|
||||
|
||||
out = []
|
||||
for g, label in (("nego", "협상 (1:1)"), ("auction", "견적 (1:N)")):
|
||||
cnt = grp[g]["count"]
|
||||
rate = (grp[g]["awarded"] / cnt) if cnt else 0.0
|
||||
avg = int(sum(sav[g]) / len(sav[g])) if sav[g] else 0
|
||||
out.append(StatTypeRow(label=label, award_rate=rate, avg_savings=avg, count=cnt))
|
||||
return out
|
||||
|
||||
def _cards(self, card_rows) -> list:
|
||||
by = {int(ct): int(n) for ct, n in card_rows if ct is not None}
|
||||
return [
|
||||
StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.value, 0), avg_drop=0),
|
||||
StatCardUsage(type="wild", label="와일드카드", uses=by.get(CardType.WILD.value, 0), avg_drop=0),
|
||||
]
|
||||
|
||||
# ── 창(최근 6개월) ─────────────────────────────────────────
|
||||
def _window(self, now):
|
||||
yy, mm = now.year, now.month
|
||||
mm -= (WINDOW_MONTHS - 1)
|
||||
while mm <= 0:
|
||||
mm += 12
|
||||
yy -= 1
|
||||
window_start = now.replace(year=yy, month=mm, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
labels, ly, lm = [], yy, mm
|
||||
for _ in range(WINDOW_MONTHS):
|
||||
labels.append(f"{ly:04d}-{lm:02d}")
|
||||
lm += 1
|
||||
if lm > 12:
|
||||
lm = 1
|
||||
ly += 1
|
||||
return labels, window_start
|
||||
|
||||
# ── DB 실행 헬퍼 ───────────────────────────────────────────
|
||||
async def _read(self, fn) -> list:
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
|
||||
return rows if err == ErrorType.SUCCESS else []
|
||||
|
||||
async def _read_scalar(self, fn) -> float:
|
||||
err, val = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
|
||||
return val if err == ErrorType.SUCCESS else 0.0
|
||||
@ -51,6 +51,16 @@ class SupplierService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.suppliers = [SupplierData.model_validate(r) for r in rows]
|
||||
# 등록자명 배치 조인 — 페이지 협력사의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님).
|
||||
author_ids = list({r.user_id for r in rows if r.user_id is not None})
|
||||
if author_ids:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
suppliers.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.supplier_crud.user_name_map(s, author_ids),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
for d in res.suppliers:
|
||||
d.creator_name = name_map.get(d.user_id)
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
@ -61,6 +71,13 @@ class SupplierService:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.supplier = SupplierData.model_validate(supplier)
|
||||
if supplier.user_id is not None:
|
||||
nm_err, name_map = await DB_SESSION_MNG.execute_lambda(
|
||||
suppliers.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.supplier_crud.user_name_map(s, [supplier.user_id]),
|
||||
)
|
||||
if nm_err == ErrorType.SUCCESS:
|
||||
res.supplier.creator_name = name_map.get(supplier.user_id)
|
||||
return res
|
||||
|
||||
async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes:
|
||||
|
||||
65
negodata/backend/tests/test_card.py
Normal file
65
negodata/backend/tests/test_card.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""card 도메인 스코프 e2e — 개인 카드는 소유자만, 전체(공용) 카드는 누구나.
|
||||
스코프 규칙: user_id 있으면 개인(본인만 조회·관리) / NULL 이면 전체(모든 유저 조회·수정·삭제). 로그인은 auth_headers."""
|
||||
|
||||
|
||||
async def _create_card(client, headers, *, number, name="카드", is_shared=False, is_wildcard=False):
|
||||
r = await client.post(
|
||||
"/v1/card/create",
|
||||
json={
|
||||
"is_wildcard": is_wildcard,
|
||||
"is_shared": is_shared,
|
||||
"name": name,
|
||||
"number": number,
|
||||
"script": "안녕하세요",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
body = r.json()
|
||||
assert body["result"]["success"] is True, body
|
||||
return body["card"]["nego_card_id"]
|
||||
|
||||
|
||||
async def _list_numbers(client, headers):
|
||||
r = await client.get("/v1/card/list", headers=headers)
|
||||
return {c["number"] for c in r.json().get("cards", [])}
|
||||
|
||||
|
||||
async def test_personal_card_is_owner_only(client, auth_headers):
|
||||
"""검증: user_id 가 박힌 개인 카드는 소유자 목록·단건조회에만 노출되고 타 유저에겐 숨는다.
|
||||
기대결과: A 목록엔 있고 B 목록엔 없음, B 의 단건 조회는 success=False(CARD_NOT_FOUND)."""
|
||||
ha = await auth_headers("cardA")
|
||||
hb = await auth_headers("cardB")
|
||||
cid = await _create_card(client, ha, number="P-1", is_shared=False)
|
||||
|
||||
assert "P-1" in await _list_numbers(client, ha)
|
||||
assert "P-1" not in await _list_numbers(client, hb)
|
||||
assert (await client.get(f"/v1/card/{cid}", headers=hb)).json()["result"]["success"] is False
|
||||
|
||||
|
||||
async def test_shared_card_visible_to_all(client, auth_headers):
|
||||
"""검증: is_shared=True 카드는 user_id NULL 로 저장돼 모든 유저 목록·단건조회에 노출된다.
|
||||
기대결과: A·B 목록 모두에 존재, 비생성자 B 의 단건 조회 success=True, is_shared 플래그 True."""
|
||||
ha = await auth_headers("cardSA")
|
||||
hb = await auth_headers("cardSB")
|
||||
cid = await _create_card(client, ha, number="S-1", is_shared=True)
|
||||
|
||||
assert "S-1" in await _list_numbers(client, ha)
|
||||
assert "S-1" in await _list_numbers(client, hb)
|
||||
got = await client.get(f"/v1/card/{cid}", headers=hb)
|
||||
assert got.json()["result"]["success"] is True
|
||||
assert got.json()["card"]["is_shared"] is True
|
||||
|
||||
|
||||
async def test_shared_card_editable_and_deletable_by_anyone(client, auth_headers):
|
||||
"""검증: 전체(공용) 카드는 소유자가 없어 아무 유저나 수정·삭제 가능(정책: 누구나).
|
||||
기대결과: 비생성자 B 의 수정·삭제 모두 success=True, 삭제 후 목록에서 사라짐."""
|
||||
ha = await auth_headers("cardEA")
|
||||
hb = await auth_headers("cardEB")
|
||||
cid = await _create_card(client, ha, number="S-EDIT", is_shared=True)
|
||||
|
||||
upd = await client.patch(f"/v1/card/update/{cid}", json={"name": "B가 수정"}, headers=hb)
|
||||
assert upd.json()["result"]["success"] is True
|
||||
|
||||
dele = await client.delete(f"/v1/card/delete/{cid}", headers=hb)
|
||||
assert dele.json()["result"]["success"] is True
|
||||
assert "S-EDIT" not in await _list_numbers(client, hb)
|
||||
122
negodata/backend/tests/test_ownership_gating.py
Normal file
122
negodata/backend/tests/test_ownership_gating.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""소유자 게이팅 — 변경 액션은 '본인 소유' 또는 '최고관리자(OWNER)'만. 프론트 버튼 차단과 같은 규칙을 백엔드가 강제한다.
|
||||
|
||||
- item(상품): 같은 회사의 다른 일반유저는 남의 상품을 수정·삭제 못 한다(OWNER는 가능).
|
||||
- quotation(견적): 남의 견적 삭제는 비소유 일반유저 차단, OWNER 허용, user_id 미지정(내부 호출)은 스킵.
|
||||
공용 판정은 common.authz.is_owner_or_admin — 여기 통과하면 다른 변경 액션(마감·재생성·초청메일)도 같은 규칙을 탄다.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import ErrorType, QuotationStatus, QuotationType, UserRole
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from services.quotation_service import QuotationService
|
||||
|
||||
PAST = datetime(2020, 1, 1)
|
||||
|
||||
|
||||
# ===== item(상품) — HTTP e2e: 같은 회사, 다른 유저 =====
|
||||
async def test_item_update_delete_blocked_for_non_owner(client, auth_headers):
|
||||
"""검증: A가 등록한 상품을 같은 회사의 다른 일반유저 B가 수정·삭제 시도.
|
||||
기대결과: 둘 다 거부(ACCOUNT_FORBIDDEN), 상품은 원값 그대로 남는다."""
|
||||
ha = await auth_headers("item_owner") # 소유자
|
||||
hb = await auth_headers("item_other") # 같은 회사, USER
|
||||
item_id = (await client.post(
|
||||
"/v1/item/create", json={"name": "상품A", "price": 1000, "code": "OWN1"}, headers=ha
|
||||
)).json()["item"]["item_id"]
|
||||
|
||||
r_upd = await client.patch(f"/v1/item/update/{item_id}", json={"price": 9999}, headers=hb)
|
||||
assert r_upd.json()["result"]["success"] is False
|
||||
assert r_upd.json()["result"]["code"] == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||
|
||||
r_del = await client.delete(f"/v1/item/delete/{item_id}", headers=hb)
|
||||
assert r_del.json()["result"]["success"] is False
|
||||
assert r_del.json()["result"]["code"] == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||
|
||||
# 수정·삭제 모두 무산 — 원값으로 조회된다
|
||||
assert (await client.get(f"/v1/item/{item_id}", headers=ha)).json()["item"]["price"] == 1000
|
||||
|
||||
|
||||
async def test_item_delete_allowed_for_owner_role(client, auth_headers):
|
||||
"""검증: A가 등록한 상품을 같은 회사 최고관리자(OWNER)가 삭제.
|
||||
기대결과: 성공 — 소유자가 아니어도 OWNER는 허용."""
|
||||
ha = await auth_headers("item_owner2")
|
||||
hadmin = await auth_headers("item_admin", role=UserRole.OWNER.value)
|
||||
item_id = (await client.post(
|
||||
"/v1/item/create", json={"name": "상품B", "price": 500, "code": "OWN2"}, headers=ha
|
||||
)).json()["item"]["item_id"]
|
||||
|
||||
r = await client.delete(f"/v1/item/delete/{item_id}", headers=hadmin)
|
||||
assert r.json()["result"]["success"] is True
|
||||
|
||||
|
||||
# ===== quotation(견적) — 서비스 직접: 삭제 게이팅 =====
|
||||
async def test_quotation_delete_blocked_for_non_owner(db_engine):
|
||||
"""검증: 남의 견적을 비소유 일반유저(USER)가 삭제 시도.
|
||||
기대결과: 거부(ACCOUNT_FORBIDDEN) + soft-delete 안 됨(deleted=false)."""
|
||||
owner, other = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_quotation(db_engine, user_id=owner, number="DEL-NONOWNER")
|
||||
|
||||
res = await _service().delete_quotation(str(qt), None, other, UserRole.USER.value)
|
||||
|
||||
assert res.result.success is False
|
||||
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||
assert await _deleted(db_engine, qt) is False
|
||||
|
||||
|
||||
async def test_quotation_delete_allowed_for_owner_role(db_engine):
|
||||
"""검증: 남의 견적을 최고관리자(OWNER)가 삭제.
|
||||
기대결과: 성공 + deleted=true."""
|
||||
creator, admin = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_quotation(db_engine, user_id=creator, number="DEL-OWNER")
|
||||
|
||||
res = await _service().delete_quotation(str(qt), None, admin, UserRole.OWNER.value)
|
||||
|
||||
assert res.result.success is True
|
||||
assert await _deleted(db_engine, qt) is True
|
||||
|
||||
|
||||
async def test_quotation_delete_skips_gate_for_internal_call(db_engine):
|
||||
"""검증: user_id 미지정(내부/스케줄러 호출)로 삭제.
|
||||
기대결과: 소유권 검사 스킵 → 성공(회사 스코프만 적용)."""
|
||||
creator = uuid.uuid4()
|
||||
qt = await _seed_quotation(db_engine, user_id=creator, number="DEL-INTERNAL")
|
||||
|
||||
res = await _service().delete_quotation(str(qt), None)
|
||||
|
||||
assert res.result.success is True
|
||||
assert await _deleted(db_engine, qt) is True
|
||||
|
||||
|
||||
# ===== 헬퍼 =====
|
||||
async def _seed_quotation(engine, *, user_id, number):
|
||||
"""견적 1건 시드(삭제 게이팅 확인용 — 상태는 무관하므로 CLOSED로 고정)."""
|
||||
qt_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotations "
|
||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
||||
" round, iteration, start_time, end_time, deleted) VALUES "
|
||||
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적', :number, :type, :status, "
|
||||
" 1, 0, :t, :t, false)"
|
||||
),
|
||||
{
|
||||
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
|
||||
"version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value,
|
||||
"status": QuotationStatus.CLOSED.value, "t": PAST,
|
||||
},
|
||||
)
|
||||
return qt_id
|
||||
|
||||
|
||||
async def _deleted(engine, qt_id) -> bool:
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT deleted FROM quotations WHERE qt_id = :q"), {"q": qt_id}
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
def _service():
|
||||
return QuotationService(QuotationCRUD())
|
||||
@ -1,8 +1,8 @@
|
||||
"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
|
||||
"""앵커링 v1.2 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
|
||||
|
||||
이식 명세: schedules/anchoring/docs/인수인계.md §1.
|
||||
- 앵커가 = 목표가 × (1000 − anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제
|
||||
- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백,
|
||||
- 조정 이력 없음 / 매핑 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백,
|
||||
견적 생성은 실패하지 않는다(규칙 6)
|
||||
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지)
|
||||
"""
|
||||
@ -22,12 +22,12 @@ BASE_VALUE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전
|
||||
|
||||
|
||||
async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engine, company_id):
|
||||
"""검증: anchoring 스키마가 아예 없는 DB 에서 supplier_type=1(유통) 견적 생성.
|
||||
"""검증: anchoring 스키마가 아예 없는 DB 에서 supply_type=1(유통) 매핑으로 견적 생성.
|
||||
기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), anchoring_value=10 박제."""
|
||||
await _drop_anchoring(db_engine)
|
||||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||||
|
||||
res = await _create(item_ids=[item], supplier_type=1)
|
||||
res = await _create(db_engine, item_ids=[item], supply_type=1)
|
||||
|
||||
assert res.result.success is True
|
||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200
|
||||
@ -45,7 +45,7 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id)
|
||||
tp_miss = int(5_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||
await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp_hit), value_after=50)
|
||||
|
||||
res = await _create(item_ids=[item_hit, item_miss], supplier_type=1)
|
||||
res = await _create(db_engine, item_ids=[item_hit, item_miss], supply_type=1)
|
||||
|
||||
assert res.result.success is True
|
||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||
@ -53,15 +53,15 @@ async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id)
|
||||
assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE)
|
||||
|
||||
|
||||
async def test_supplier_type_unset_uses_base_value(db_engine, company_id):
|
||||
"""검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가.
|
||||
async def test_supply_type_unset_uses_base_value(db_engine, company_id):
|
||||
"""검증: supply_type 미지정(None) 매핑으로 견적 생성 — 칸(회사×유형×구간) 구성 불가.
|
||||
기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10‰ 박제."""
|
||||
await _reset_anchoring(db_engine)
|
||||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||
await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50)
|
||||
|
||||
res = await _create(item_ids=[item], supplier_type=None)
|
||||
res = await _create(db_engine, item_ids=[item], supply_type=None)
|
||||
|
||||
assert res.result.success is True
|
||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||
@ -75,7 +75,7 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa
|
||||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||||
supplier = uuid.uuid4()
|
||||
|
||||
res1 = await _create(item_ids=[item], supplier_type=1, supplier_ids=[supplier])
|
||||
res1 = await _create(db_engine, item_ids=[item], supply_type=1, supplier_ids=[supplier])
|
||||
assert res1.result.success is True
|
||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||
rows1 = await _session_anchor_rows(db_engine, res1.qt_id)
|
||||
@ -108,20 +108,40 @@ def _service():
|
||||
return QuotationService(QuotationCRUD())
|
||||
|
||||
|
||||
async def _create(*, item_ids, supplier_type, supplier_ids=None):
|
||||
"""supplier_type 을 지정해 견적 1건 생성(공급사 기본 1곳)."""
|
||||
async def _create(engine, *, item_ids, supply_type, supplier_ids=None):
|
||||
"""supplier_items.supply_type 매핑을 시드한 뒤 견적 1건 생성(공급사 기본 1곳)."""
|
||||
supplier_ids = supplier_ids or [uuid.uuid4()]
|
||||
await _seed_supplier_items(engine, item_ids, supplier_ids, supply_type=supply_type)
|
||||
req = Req_CreateQuotation(
|
||||
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(앵커는 세팅과 무관해짐)
|
||||
name="앵커링검증",
|
||||
type=QuotationType.NEW_QUOTE.value,
|
||||
end_time=FUTURE,
|
||||
supplier_type=supplier_type,
|
||||
item_ids=list(item_ids),
|
||||
supplier_ids=supplier_ids or [uuid.uuid4()],
|
||||
supplier_ids=supplier_ids,
|
||||
)
|
||||
return await _service().create_quotation(str(uuid.uuid4()), req)
|
||||
|
||||
|
||||
async def _seed_supplier_items(engine, item_ids, supplier_ids, *, supply_type):
|
||||
async with engine.begin() as conn:
|
||||
for item_id in item_ids:
|
||||
for supplier_id in supplier_ids:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO supplier_items "
|
||||
"(supplier_item_id, supplier_id, item_id, supply_type) "
|
||||
"VALUES (:id, :supplier_id, :item_id, :supply_type)"
|
||||
),
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"supplier_id": supplier_id,
|
||||
"item_id": item_id,
|
||||
"supply_type": supply_type if supply_type is not None else 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _seed_item(engine, company_id, *, internet_lowest):
|
||||
"""상품 1건 시드(인터넷최저가만). NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음)."""
|
||||
item_id = uuid.uuid4()
|
||||
|
||||
206
negodata/backend/tests/test_quotation_award.py
Normal file
206
negodata/backend/tests/test_quotation_award.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""개찰 견적 직접 낙찰(award_quotation) 테스트 — 담당자가 개찰(낙찰자 미정 마감) 견적의 낙찰자를 직접 지정.
|
||||
|
||||
직접 낙찰은 자동 낙찰(close_and_decide)과 결과 컬럼은 같되(close_reason=AWARDED, preferred_sp_*),
|
||||
알림에 manual 플래그로 '직접' 낙찰임을 남긴다. 다음을 본다:
|
||||
· 개찰 + 투찰(DONE) 협력사 지정 → 낙찰 확정 + 알림 SUCCESS(manual=True)
|
||||
· 개찰 아님(이미 낙찰) → 거부(INVALID_REQUEST_DATA), 알림 없음
|
||||
· 후보 아닌 협력사 지정 → 거부, close_reason 유지
|
||||
· 낙찰 후 재지정(재클릭) → 거부(동시성 가드), 알림 1건 유지
|
||||
|
||||
세션의 협상 결과(협상완료/입찰가)와 개찰 상태(close_reason)는 협상/마감에서만 생기는 값이라 SQL 로 직접 넣는다.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import CloseReason, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from services.quotation_service import QuotationService
|
||||
|
||||
PAST = datetime(2020, 1, 1)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def clean(db_engine):
|
||||
"""conftest 는 notifications 를 비우지 않는다 → 알림 단언이 다른 테스트에 안 흔들리게 여기서 함께 비운다."""
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("TRUNCATE TABLE sessions, quotations, notifications RESTART IDENTITY CASCADE"))
|
||||
return db_engine
|
||||
|
||||
|
||||
async def test_award_opened_sets_winner_and_notifies(clean):
|
||||
"""검증: 개찰(동가) 견적 + 투찰 협력사 2건(A=100, B=120) 중 A 를 직접 낙찰.
|
||||
기대결과: close_reason→낙찰, preferred_sp=A, 동가플래그 해제 + 알림 SUCCESS(manual=True, winner_price=100)."""
|
||||
engine = clean
|
||||
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=user_id, number="A-WIN", close_reason=CloseReason.OPEN_EQUAL.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
||||
|
||||
assert res.result.success is True
|
||||
row = await _quotation(engine, qt)
|
||||
assert row.close_reason == CloseReason.AWARDED.value
|
||||
assert row.preferred_sp_yn is True
|
||||
assert str(row.preferred_sp_id) == str(supplier_a)
|
||||
assert row.equal_bid_yn is False
|
||||
|
||||
notis = await _notifications(engine, user_id)
|
||||
assert len(notis) == 1
|
||||
type_, data, ref = notis[0]
|
||||
assert type_ == 1 # NotificationType.SUCCESS
|
||||
assert data["manual"] is True
|
||||
assert data["winner_price"] == 100
|
||||
assert str(ref) == str(qt)
|
||||
|
||||
|
||||
async def test_award_rejects_when_not_opened(clean):
|
||||
"""검증: 이미 낙찰된 견적(close_reason=AWARDED)에 직접 낙찰을 다시 시도.
|
||||
기대결과: 거부(INVALID_REQUEST_DATA) + 새 알림 없음."""
|
||||
engine = clean
|
||||
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
||||
|
||||
assert res.result.success is False
|
||||
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
||||
assert len(await _notifications(engine, user_id)) == 0
|
||||
|
||||
|
||||
async def test_award_rejects_unknown_supplier(clean):
|
||||
"""검증: 개찰 견적에, 투찰 후보가 아닌 협력사 id 를 지정.
|
||||
기대결과: 거부 + close_reason 은 개찰(OPEN_PRICE) 그대로 유지, 알림 없음."""
|
||||
engine = clean
|
||||
user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger)
|
||||
|
||||
assert res.result.success is False
|
||||
row = await _quotation(engine, qt)
|
||||
assert row.close_reason == CloseReason.OPEN_PRICE.value
|
||||
assert row.preferred_sp_id is None
|
||||
assert len(await _notifications(engine, user_id)) == 0
|
||||
|
||||
|
||||
async def test_award_is_idempotent(clean):
|
||||
"""검증: 직접 낙찰 성공 후 같은 견적에 재지정(재클릭/경합).
|
||||
기대결과: 2번째는 거부(이미 낙찰) + 알림은 1건만 유지(동시성 가드가 한 번만 통과)."""
|
||||
engine = clean
|
||||
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
|
||||
first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
||||
second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a)
|
||||
|
||||
assert first.result.success is True
|
||||
assert second.result.success is False
|
||||
assert len(await _notifications(engine, user_id)) == 1
|
||||
|
||||
|
||||
async def test_award_rejects_non_owner(clean):
|
||||
"""검증: 남의 개찰 견적을 일반 유저(비소유·USER)가 직접 낙찰 시도.
|
||||
기대결과: 거부(ACCOUNT_FORBIDDEN) + close_reason 개찰 유지 + 알림 없음."""
|
||||
engine = clean
|
||||
owner, other, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a)
|
||||
|
||||
assert res.result.success is False
|
||||
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
||||
row = await _quotation(engine, qt)
|
||||
assert row.close_reason == CloseReason.OPEN_PRICE.value
|
||||
assert row.preferred_sp_id is None
|
||||
assert len(await _notifications(engine, owner)) == 0
|
||||
|
||||
|
||||
async def test_award_allows_owner_role(clean):
|
||||
"""검증: 남의 개찰 견적을 최고관리자(OWNER)가 직접 낙찰.
|
||||
기대결과: 낙찰 성공 + 알림은 견적 작성자(owner) 인박스에 남는다(호출자가 아니라)."""
|
||||
engine = clean
|
||||
creator, admin, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||
qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value)
|
||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
|
||||
|
||||
res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a)
|
||||
|
||||
assert res.result.success is True
|
||||
row = await _quotation(engine, qt)
|
||||
assert row.close_reason == CloseReason.AWARDED.value
|
||||
assert str(row.preferred_sp_id) == str(supplier_a)
|
||||
assert len(await _notifications(engine, creator)) == 1
|
||||
assert len(await _notifications(engine, admin)) == 0
|
||||
|
||||
|
||||
# ===== 헬퍼 =====
|
||||
async def _seed_opened(engine, *, user_id, number, close_reason, round_=1):
|
||||
"""개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작."""
|
||||
qt_id = uuid.uuid4()
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO quotations "
|
||||
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
|
||||
" round, iteration, start_time, end_time, deleted) VALUES "
|
||||
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, :close_reason, "
|
||||
" :round, 0, :start_time, :end_time, false)"
|
||||
),
|
||||
{
|
||||
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
|
||||
"version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value,
|
||||
"status": QuotationStatus.CLOSED.value, "close_reason": close_reason,
|
||||
"round": round_, "start_time": PAST, "end_time": PAST,
|
||||
},
|
||||
)
|
||||
return qt_id
|
||||
|
||||
|
||||
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
|
||||
"""세션 1건 시드(공급사 협상 1건). status/bid_price 로 협상완료·입찰가를 만든다."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||
" target_price, status, bid_price, end_time) VALUES "
|
||||
"(:session_id, :quotation_id, :item_id, :supplier_id, 'Q', 1, :qt_type, "
|
||||
" 0, :status, :bid_price, :end_time)"
|
||||
),
|
||||
{
|
||||
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
|
||||
"supplier_id": supplier_id or uuid.uuid4(), "qt_type": QuotationType.REQUOTE.value,
|
||||
"status": status, "bid_price": bid_price, "end_time": PAST,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _quotation(engine, qt_id):
|
||||
"""견적 1행(마감 결과 컬럼 확인용)."""
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT close_reason, preferred_sp_yn, preferred_sp_id, equal_bid_yn "
|
||||
"FROM quotations WHERE qt_id = :qt_id"),
|
||||
{"qt_id": qt_id},
|
||||
)).one()
|
||||
|
||||
|
||||
async def _notifications(engine, user_id):
|
||||
"""user_id(작성자) 인박스 알림 (type, data, ref_qt_id) — 생성순."""
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT type, data, ref_qt_id FROM notifications WHERE user_id = :uid ORDER BY created_at"),
|
||||
{"uid": user_id},
|
||||
)).all()
|
||||
|
||||
|
||||
def _service():
|
||||
return QuotationService(QuotationCRUD())
|
||||
377
negodata/front/package-lock.json
generated
377
negodata/front/package-lock.json
generated
@ -25,6 +25,7 @@
|
||||
"react-dom": "^19.0.1",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router": "^7.17.0",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"slate": "^0.118.1",
|
||||
"slate-dom": "^0.119.0",
|
||||
@ -2431,6 +2432,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
|
||||
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@ -2829,6 +2866,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@ -3639,6 +3682,69 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/es-aggregate-error": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz",
|
||||
@ -3808,6 +3914,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@ -4699,6 +4811,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
@ -4779,6 +5012,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@ -5249,6 +5488,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/es6-promise": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
|
||||
@ -5354,6 +5603,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@ -6319,6 +6574,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
@ -8836,6 +9100,36 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@ -8979,6 +9273,67 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@ -11043,6 +11398,28 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
"react-dom": "^19.0.1",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router": "^7.17.0",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"slate": "^0.118.1",
|
||||
"slate-dom": "^0.119.0",
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { CardDataUserId } from './cardDataUserId';
|
||||
import type { CardDataCreatorName } from './cardDataCreatorName';
|
||||
import type { CardDataName } from './cardDataName';
|
||||
import type { CardDataNumber } from './cardDataNumber';
|
||||
import type { CardDataScript } from './cardDataScript';
|
||||
@ -20,6 +21,8 @@ export interface CardData {
|
||||
nego_card_id: string;
|
||||
user_id?: CardDataUserId;
|
||||
is_wildcard?: boolean;
|
||||
is_shared?: boolean;
|
||||
creator_name?: CardDataCreatorName;
|
||||
name?: CardDataName;
|
||||
number?: CardDataNumber;
|
||||
script?: CardDataScript;
|
||||
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ResLastSupplierTypeMsg = string | null;
|
||||
export type CardDataCreatorName = string | null;
|
||||
@ -9,6 +9,7 @@ export * from './bodyUploadItemImageV1ItemImagePost';
|
||||
export * from './cardData';
|
||||
export * from './cardDataCondition';
|
||||
export * from './cardDataCreatedAt';
|
||||
export * from './cardDataCreatorName';
|
||||
export * from './cardDataEditScript';
|
||||
export * from './cardDataMemo';
|
||||
export * from './cardDataName';
|
||||
@ -53,6 +54,7 @@ export * from './itemData';
|
||||
export * from './itemDataCategory';
|
||||
export * from './itemDataCode';
|
||||
export * from './itemDataCreatedAt';
|
||||
export * from './itemDataCreatorName';
|
||||
export * from './itemDataDeliveryFeeYn';
|
||||
export * from './itemDataDeliveryType';
|
||||
export * from './itemDataImageUrl';
|
||||
@ -112,7 +114,6 @@ export * from './quotationDataOverAction';
|
||||
export * from './quotationDataPreferredSpId';
|
||||
export * from './quotationDataPreferredSpName';
|
||||
export * from './quotationDataPreferredSpYn';
|
||||
export * from './quotationDataSupplierType';
|
||||
export * from './quotationDataUpdatedAt';
|
||||
export * from './quotationSettingData';
|
||||
export * from './quotationSettingDataCreatedAt';
|
||||
@ -120,6 +121,7 @@ export * from './quotationSettingDataUpdatedAt';
|
||||
export * from './quotationSettingDataUserId';
|
||||
export * from './quotationStatus';
|
||||
export * from './quotationType';
|
||||
export * from './reqAwardQuotation';
|
||||
export * from './reqBulkMapByNames';
|
||||
export * from './reqCheckCodes';
|
||||
export * from './reqCreateCard';
|
||||
@ -158,7 +160,6 @@ export * from './reqCreateQuotationMidAction';
|
||||
export * from './reqCreateQuotationOverAction';
|
||||
export * from './reqCreateQuotationSetting';
|
||||
export * from './reqCreateQuotationStartTime';
|
||||
export * from './reqCreateQuotationSupplierType';
|
||||
export * from './reqCreateQuotationVersionId';
|
||||
export * from './reqCreateSupplier';
|
||||
export * from './reqCreateSupplierCode';
|
||||
@ -266,10 +267,6 @@ export * from './resItemListMsg';
|
||||
export * from './resItemMsg';
|
||||
export * from './resItemSupplyTypeList';
|
||||
export * from './resItemSupplyTypeListMsg';
|
||||
export * from './resLastSupplierType';
|
||||
export * from './resLastSupplierTypeMsg';
|
||||
export * from './resLastSupplierTypeQtNumber';
|
||||
export * from './resLastSupplierTypeSupplierType';
|
||||
export * from './resLogin';
|
||||
export * from './resLoginMsg';
|
||||
export * from './resLowestPriceResult';
|
||||
@ -319,6 +316,8 @@ export * from './resRefreshTokenMsg';
|
||||
export * from './resSessionChat';
|
||||
export * from './resSessionChatMsg';
|
||||
export * from './resSessionChatSessionId';
|
||||
export * from './resStatisticsSummary';
|
||||
export * from './resStatisticsSummaryMsg';
|
||||
export * from './resSupplier';
|
||||
export * from './resSupplierItem';
|
||||
export * from './resSupplierItemList';
|
||||
@ -348,9 +347,18 @@ export * from './sessionDataRejectDeliveryType';
|
||||
export * from './sessionDataRejectPrice';
|
||||
export * from './sessionDataRejectReason';
|
||||
export * from './sessionStatus';
|
||||
export * from './statCardUsage';
|
||||
export * from './statCategory';
|
||||
export * from './statKpi';
|
||||
export * from './statMonthPoint';
|
||||
export * from './statOutcome';
|
||||
export * from './statParticipation';
|
||||
export * from './statScope';
|
||||
export * from './statTypeRow';
|
||||
export * from './supplierData';
|
||||
export * from './supplierDataCode';
|
||||
export * from './supplierDataCreatedAt';
|
||||
export * from './supplierDataCreatorName';
|
||||
export * from './supplierDataManagerContactNumber';
|
||||
export * from './supplierDataManagerEmail';
|
||||
export * from './supplierDataManagerName';
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ItemDataCreatorName } from './itemDataCreatorName';
|
||||
import type { ItemDataCode } from './itemDataCode';
|
||||
import type { ItemDataCategory } from './itemDataCategory';
|
||||
import type { ItemDataImageUrl } from './itemDataImageUrl';
|
||||
@ -28,6 +29,7 @@ export interface ItemData {
|
||||
item_id: string;
|
||||
company_id: string;
|
||||
user_id: string;
|
||||
creator_name?: ItemDataCreatorName;
|
||||
name: string;
|
||||
code?: ItemDataCode;
|
||||
category?: ItemDataCategory;
|
||||
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ResLastSupplierTypeQtNumber = string | null;
|
||||
export type ItemDataCreatorName = string | null;
|
||||
@ -11,7 +11,6 @@ import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
|
||||
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
|
||||
import type { QuotationDataMemo } from './quotationDataMemo';
|
||||
import type { QuotationDataMdPrice } from './quotationDataMdPrice';
|
||||
import type { QuotationDataSupplierType } from './quotationDataSupplierType';
|
||||
import type { QuotationDataPreferredSpYn } from './quotationDataPreferredSpYn';
|
||||
import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId';
|
||||
import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName';
|
||||
@ -43,7 +42,6 @@ export interface QuotationData {
|
||||
manager_contact_number?: QuotationDataManagerContactNumber;
|
||||
memo?: QuotationDataMemo;
|
||||
md_price?: QuotationDataMdPrice;
|
||||
supplier_type?: QuotationDataSupplierType;
|
||||
iteration?: number;
|
||||
preferred_sp_yn?: QuotationDataPreferredSpYn;
|
||||
preferred_sp_id?: QuotationDataPreferredSpId;
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { SupplierType } from './supplierType';
|
||||
|
||||
export type QuotationDataSupplierType = SupplierType | null;
|
||||
export interface ReqAwardQuotation {
|
||||
winner_supplier_id: string;
|
||||
}
|
||||
@ -13,6 +13,7 @@ import type { ReqCreateCardMemo } from './reqCreateCardMemo';
|
||||
|
||||
export interface ReqCreateCard {
|
||||
is_wildcard?: boolean;
|
||||
is_shared?: boolean;
|
||||
name?: ReqCreateCardName;
|
||||
number?: ReqCreateCardNumber;
|
||||
script?: ReqCreateCardScript;
|
||||
|
||||
@ -11,7 +11,6 @@ import type { ReqCreateQuotationManagerEmail } from './reqCreateQuotationManager
|
||||
import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber';
|
||||
import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
|
||||
import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice';
|
||||
import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType';
|
||||
import type { ReqCreateQuotationMidAction } from './reqCreateQuotationMidAction';
|
||||
import type { ReqCreateQuotationOverAction } from './reqCreateQuotationOverAction';
|
||||
|
||||
@ -29,7 +28,6 @@ export interface ReqCreateQuotation {
|
||||
manager_contact_number?: ReqCreateQuotationManagerContactNumber;
|
||||
memo?: ReqCreateQuotationMemo;
|
||||
md_price?: ReqCreateQuotationMdPrice;
|
||||
supplier_type?: ReqCreateQuotationSupplierType;
|
||||
item_ids?: string[];
|
||||
supplier_ids?: string[];
|
||||
card_ids?: string[];
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ErrorInfo } from './errorInfo';
|
||||
import type { ResLastSupplierTypeMsg } from './resLastSupplierTypeMsg';
|
||||
import type { ResLastSupplierTypeSupplierType } from './resLastSupplierTypeSupplierType';
|
||||
import type { ResLastSupplierTypeQtNumber } from './resLastSupplierTypeQtNumber';
|
||||
|
||||
export interface ResLastSupplierType {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResLastSupplierTypeMsg;
|
||||
supplier_type?: ResLastSupplierTypeSupplierType;
|
||||
qt_number?: ResLastSupplierTypeQtNumber;
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { SupplierType } from './supplierType';
|
||||
|
||||
export type ResLastSupplierTypeSupplierType = SupplierType | null;
|
||||
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ErrorInfo } from './errorInfo';
|
||||
import type { ResStatisticsSummaryMsg } from './resStatisticsSummaryMsg';
|
||||
import type { StatScope } from './statScope';
|
||||
|
||||
export interface ResStatisticsSummary {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResStatisticsSummaryMsg;
|
||||
company?: StatScope;
|
||||
mine?: StatScope;
|
||||
}
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ReqCreateQuotationSupplierType = number | null;
|
||||
export type ResStatisticsSummaryMsg = string | null;
|
||||
13
negodata/front/src/api/generated/model/statCardUsage.ts
Normal file
13
negodata/front/src/api/generated/model/statCardUsage.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface StatCardUsage {
|
||||
type: string;
|
||||
label: string;
|
||||
uses?: number;
|
||||
avg_drop?: number;
|
||||
}
|
||||
12
negodata/front/src/api/generated/model/statCategory.ts
Normal file
12
negodata/front/src/api/generated/model/statCategory.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface StatCategory {
|
||||
category: string;
|
||||
savings?: number;
|
||||
count?: number;
|
||||
}
|
||||
16
negodata/front/src/api/generated/model/statKpi.ts
Normal file
16
negodata/front/src/api/generated/model/statKpi.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface StatKpi {
|
||||
total_savings?: number;
|
||||
savings_rate?: number;
|
||||
award_rate?: number;
|
||||
anchor_reach_rate?: number;
|
||||
savings_delta_mom?: number;
|
||||
closed_count?: number;
|
||||
regen_avg_round?: number;
|
||||
}
|
||||
12
negodata/front/src/api/generated/model/statMonthPoint.ts
Normal file
12
negodata/front/src/api/generated/model/statMonthPoint.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface StatMonthPoint {
|
||||
month: string;
|
||||
savings?: number;
|
||||
rate?: number;
|
||||
}
|
||||
14
negodata/front/src/api/generated/model/statOutcome.ts
Normal file
14
negodata/front/src/api/generated/model/statOutcome.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface StatOutcome {
|
||||
awarded?: number;
|
||||
open_price?: number;
|
||||
open_equal?: number;
|
||||
open_noshow?: number;
|
||||
open_reject?: number;
|
||||
}
|
||||
12
negodata/front/src/api/generated/model/statParticipation.ts
Normal file
12
negodata/front/src/api/generated/model/statParticipation.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface StatParticipation {
|
||||
bid?: number;
|
||||
no_participate?: number;
|
||||
rejected?: number;
|
||||
}
|
||||
23
negodata/front/src/api/generated/model/statScope.ts
Normal file
23
negodata/front/src/api/generated/model/statScope.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { StatKpi } from './statKpi';
|
||||
import type { StatMonthPoint } from './statMonthPoint';
|
||||
import type { StatOutcome } from './statOutcome';
|
||||
import type { StatParticipation } from './statParticipation';
|
||||
import type { StatTypeRow } from './statTypeRow';
|
||||
import type { StatCategory } from './statCategory';
|
||||
import type { StatCardUsage } from './statCardUsage';
|
||||
|
||||
export interface StatScope {
|
||||
kpi?: StatKpi;
|
||||
trend?: StatMonthPoint[];
|
||||
outcome?: StatOutcome;
|
||||
participation?: StatParticipation;
|
||||
type_split?: StatTypeRow[];
|
||||
categories?: StatCategory[];
|
||||
cards?: StatCardUsage[];
|
||||
}
|
||||
13
negodata/front/src/api/generated/model/statTypeRow.ts
Normal file
13
negodata/front/src/api/generated/model/statTypeRow.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface StatTypeRow {
|
||||
label: string;
|
||||
award_rate?: number;
|
||||
avg_savings?: number;
|
||||
count?: number;
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { SupplierDataCreatorName } from './supplierDataCreatorName';
|
||||
import type { SupplierDataCode } from './supplierDataCode';
|
||||
import type { SupplierDataManagerName } from './supplierDataManagerName';
|
||||
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
|
||||
@ -16,6 +17,7 @@ export interface SupplierData {
|
||||
supplier_id: string;
|
||||
company_id: string;
|
||||
user_id: string;
|
||||
creator_name?: SupplierDataCreatorName;
|
||||
name: string;
|
||||
code?: SupplierDataCode;
|
||||
manager_name?: SupplierDataManagerName;
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type SupplierDataCreatorName = string | null;
|
||||
@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
|
||||
없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응.
|
||||
* partner.supplier_items.supply_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
|
||||
없음은 상품-협력사 매핑에서 유형 미지정 상태를 뜻한다.
|
||||
*/
|
||||
export type SupplierType = typeof SupplierType[keyof typeof SupplierType];
|
||||
|
||||
|
||||
@ -26,11 +26,11 @@ import type {
|
||||
import type {
|
||||
HTTPValidationError,
|
||||
ListQuotationsParams,
|
||||
ReqAwardQuotation,
|
||||
ReqCreateQuotation,
|
||||
ReqRegenerateQuotation,
|
||||
ResCreateQuotation,
|
||||
ResDeleteQuotation,
|
||||
ResLastSupplierType,
|
||||
ResNotifySessions,
|
||||
ResQuotation,
|
||||
ResQuotationCards,
|
||||
@ -269,6 +269,71 @@ export const useStopQuotation = <TError = void | HTTPValidationError,
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 개찰 견적 직접 낙찰
|
||||
*/
|
||||
export const awardQuotation = (
|
||||
qtId: string,
|
||||
reqAwardQuotation: ReqAwardQuotation,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResQuotation>(
|
||||
{url: `/v1/quotation/award/${qtId}`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqAwardQuotation, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getAwardQuotationMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext> => {
|
||||
|
||||
const mutationKey = ['awardQuotation'];
|
||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, request: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof awardQuotation>>, {qtId: string;data: ReqAwardQuotation}> = (props) => {
|
||||
const {qtId,data} = props ?? {};
|
||||
|
||||
return awardQuotation(qtId,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type AwardQuotationMutationResult = NonNullable<Awaited<ReturnType<typeof awardQuotation>>>
|
||||
export type AwardQuotationMutationBody = ReqAwardQuotation
|
||||
export type AwardQuotationMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 개찰 견적 직접 낙찰
|
||||
*/
|
||||
export const useAwardQuotation = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof awardQuotation>>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof awardQuotation>>,
|
||||
TError,
|
||||
{qtId: string;data: ReqAwardQuotation},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getAwardQuotationMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 견적 재생성(다음 라운드)
|
||||
*/
|
||||
export const regenerateQuotation = (
|
||||
@ -1070,96 +1135,6 @@ export const useDeleteQuotation = <TError = void | HTTPValidationError,
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 협력사 직전 견적 유형
|
||||
*/
|
||||
export const getSupplierLastType = (
|
||||
supplierId: string,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResLastSupplierType>(
|
||||
{url: `/v1/quotation/supplier/${supplierId}/last-type`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetSupplierLastTypeQueryKey = (supplierId?: string,) => {
|
||||
return [
|
||||
`/v1/quotation/supplier/${supplierId}/last-type`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetSupplierLastTypeQueryOptions = <TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSupplierLastTypeQueryKey(supplierId);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSupplierLastType>>> = ({ signal }) => getSupplierLastType(supplierId, requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetSupplierLastTypeQueryResult = NonNullable<Awaited<ReturnType<typeof getSupplierLastType>>>
|
||||
export type GetSupplierLastTypeQueryError = void | HTTPValidationError
|
||||
|
||||
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 협력사 직전 견적 유형
|
||||
*/
|
||||
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetSupplierLastTypeQueryOptions(supplierId,options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@ -1253,4 +1228,3 @@ export function useGetQuotation<TData = Awaited<ReturnType<typeof getQuotation>>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
124
negodata/front/src/api/generated/statistics/statistics.ts
Normal file
124
negodata/front/src/api/generated/statistics/statistics.ts
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import {
|
||||
useQuery
|
||||
} from '@tanstack/react-query';
|
||||
import type {
|
||||
DataTag,
|
||||
DefinedInitialDataOptions,
|
||||
DefinedUseQueryResult,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UndefinedInitialDataOptions,
|
||||
UseQueryOptions,
|
||||
UseQueryResult
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
ResStatisticsSummary
|
||||
} from '.././model';
|
||||
|
||||
import { customFetch } from '../../mutator/custom-fetch';
|
||||
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 통계 요약(회사 전체 + 내 견적)
|
||||
*/
|
||||
export const getStatisticsSummary = (
|
||||
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResStatisticsSummary>(
|
||||
{url: `/v1/statistics/summary`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetStatisticsSummaryQueryKey = () => {
|
||||
return [
|
||||
`/v1/statistics/summary`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetStatisticsSummaryQueryOptions = <TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetStatisticsSummaryQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getStatisticsSummary>>> = ({ signal }) => getStatisticsSummary(requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetStatisticsSummaryQueryResult = NonNullable<Awaited<ReturnType<typeof getStatisticsSummary>>>
|
||||
export type GetStatisticsSummaryQueryError = void
|
||||
|
||||
|
||||
export function useGetStatisticsSummary<TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>(
|
||||
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getStatisticsSummary>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getStatisticsSummary>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetStatisticsSummary<TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getStatisticsSummary>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getStatisticsSummary>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetStatisticsSummary<TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 통계 요약(회사 전체 + 내 견적)
|
||||
*/
|
||||
|
||||
export function useGetStatisticsSummary<TData = Awaited<ReturnType<typeof getStatisticsSummary>>, TError = void>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getStatisticsSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetStatisticsSummaryQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import {isLoggedIn, hasRole} from '../stores/auth';
|
||||
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
||||
import LoginPage from '../pages/login';
|
||||
import DashboardPage from '../pages/dashboard';
|
||||
import StatisticsPage from '../pages/statistics';
|
||||
import ForbiddenPage from '../pages/forbidden';
|
||||
import NotFoundPage from '../pages/not-found';
|
||||
import ProductsPage from '../pages/products';
|
||||
@ -56,6 +57,7 @@ export const router = createBrowserRouter([
|
||||
Component: AuthenticatedLayout,
|
||||
children: [
|
||||
{path: 'dashboard', Component: DashboardPage},
|
||||
{path: 'statistics', Component: StatisticsPage},
|
||||
{path: 'products', Component: ProductsPage},
|
||||
{path: 'partners', Component: PartnersPage},
|
||||
{path: 'quotation', Component: QuotationPage},
|
||||
|
||||
@ -6,6 +6,7 @@ import {showToast} from '@/lib/notify';
|
||||
|
||||
const PAGE_TO_PATH: Record<PageType, string> = {
|
||||
DASHBOARD: '/dashboard',
|
||||
STATISTICS: '/statistics',
|
||||
PRODUCTS: '/products',
|
||||
PARTNERS: '/partners',
|
||||
QUOTATION: '/quotation',
|
||||
|
||||
@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BarChart3,
|
||||
Briefcase,
|
||||
Users,
|
||||
UserCog,
|
||||
@ -36,6 +37,7 @@ type SidebarUser = ReturnType<typeof useAuth>['user'];
|
||||
// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터).
|
||||
const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [
|
||||
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
|
||||
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
|
||||
{ type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' },
|
||||
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
|
||||
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' },
|
||||
@ -45,6 +47,7 @@ const menuItems: { type: PageType; label: string; icon: ElementType; id: string;
|
||||
|
||||
const pageLabelMap: Record<PageType, string> = {
|
||||
DASHBOARD: '대시보드',
|
||||
STATISTICS: '통계',
|
||||
PRODUCTS: '상품관리',
|
||||
PARTNERS: '협력사관리',
|
||||
QUOTATION: '견적관리',
|
||||
|
||||
281
negodata/front/src/components/ui/chart.tsx
Normal file
281
negodata/front/src/components/ui/chart.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import * as React from 'react';
|
||||
import * as RechartsPrimitive from 'recharts';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// shadcn Chart 래퍼 (Recharts 기반). CLI 대화형 프롬프트 회피 위해 직접 작성.
|
||||
// config 의 각 키 색을 [data-chart] 스코프의 --color-<key> CSS 변수로 주입 → 다크모드/토큰 일관.
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = { config: ChartConfig };
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
|
||||
if (!colorConfig.length) return null;
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join('\n'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TooltipContentProps = {
|
||||
active?: boolean;
|
||||
payload?: any[];
|
||||
label?: unknown;
|
||||
className?: string;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
labelFormatter?: (value: unknown, payload: any[]) => React.ReactNode;
|
||||
labelClassName?: string;
|
||||
formatter?: (value: unknown, name: unknown, item: any, index: number, payload: any) => React.ReactNode;
|
||||
color?: string;
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
};
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: TooltipContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) return null;
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
if (labelFormatter) {
|
||||
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||
}
|
||||
if (!value) return null;
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload?.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey ?? index}
|
||||
className={cn(
|
||||
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||
indicator === 'dot' && 'items-center',
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||
})}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div className={cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
||||
</div>
|
||||
{item.value !== undefined && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{typeof item.value === 'number' ? item.value.toLocaleString() : String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
type LegendContentProps = {
|
||||
className?: string;
|
||||
hideIcon?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
payload?: any[];
|
||||
verticalAlign?: 'top' | 'bottom' | 'middle';
|
||||
nameKey?: string;
|
||||
};
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
nameKey,
|
||||
}: LegendContentProps) {
|
||||
const { config } = useChart();
|
||||
if (!payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
return (
|
||||
<div key={String(item.value)} className="flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground">
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: item.color }} />
|
||||
)}
|
||||
<span className="text-muted-foreground">{itemConfig?.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// payload 항목에서 config 매칭 키를 찾는다(shadcn 원본 로직).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: any, key: string) {
|
||||
if (typeof payload !== 'object' || payload === null) return undefined;
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key] === 'string') {
|
||||
configLabelKey = payload[key];
|
||||
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key] === 'string') {
|
||||
configLabelKey = payloadPayload[key];
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
26
negodata/front/src/components/ui/phone-input.tsx
Normal file
26
negodata/front/src/components/ui/phone-input.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Input } from './input';
|
||||
import { formatPhoneKR, normalizePhone } from '@/lib/phone';
|
||||
|
||||
// 연락처 전용 입력. 표시는 하이픈 자동(010/02/031/1588 등), 밖으로 넘기는 값은 숫자만.
|
||||
// react-hook-form Controller 의 field(value/onChange/onBlur)를 그대로 물리면 된다.
|
||||
type PhoneInputProps = Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> & {
|
||||
value?: string; // 숫자만(정규화된 폼 값)
|
||||
onChange?: (value: string) => void; // 숫자만으로 방출
|
||||
};
|
||||
|
||||
export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput(
|
||||
{ value = '', onChange, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={formatPhoneKR(value)}
|
||||
onChange={(e) => onChange?.(normalizePhone(e.target.value))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -1,10 +1,12 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { normalizePhone } from '@/lib/phone';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { useAuth } from '../useAuth';
|
||||
import { updateMe } from '../service';
|
||||
@ -26,6 +28,7 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
@ -34,7 +37,7 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
|
||||
defaultValues: {
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
contactNumber: user?.contact ?? '',
|
||||
contactNumber: normalizePhone(user?.contact ?? ''),
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
},
|
||||
@ -98,7 +101,20 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () =>
|
||||
{/* 연락처 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">연락처</Typography>
|
||||
<Input id="profile-phone" type="text" {...register('contactNumber')} className={inputClass} placeholder="010-XXXX-XXXX" />
|
||||
<Controller
|
||||
control={control}
|
||||
name="contactNumber"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
id="profile-phone"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className={inputClass}
|
||||
placeholder="010-XXXX-XXXX"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 변경(옵션) */}
|
||||
|
||||
@ -0,0 +1,372 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { CardUsageType } from '@/api/generated/model';
|
||||
import { deserialize } from '../editor';
|
||||
import type { CardInput } from '../hooks/useCards';
|
||||
|
||||
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 검증에서 파생한다.
|
||||
type RawRow = {
|
||||
id: string;
|
||||
rowNum: number;
|
||||
kind: string; // 카드종류 원문(협상/와일드)
|
||||
code: string; // 카드번호
|
||||
title: string; // 카드이름
|
||||
script: string; // 스크립트(평문 — 변수는 {변수} 텍스트로)
|
||||
usage: string; // 카드용도 원문(공통/신규견적전용/재견적전용)
|
||||
scope: string; // 공개범위 원문(개인/전체)
|
||||
condition: string; // 와일드 전용 사용조건
|
||||
memo: string; // 와일드 전용 메모
|
||||
};
|
||||
|
||||
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
||||
|
||||
type TemplateRow = { kind: string; code: string; title: string; script: string; usage: string; scope: string; condition: string; memo: string };
|
||||
|
||||
// 카드종류 원문 → 와일드 여부. '와일드'/'wild' 포함이면 와일드, 그 외 협상.
|
||||
const isWild = (kind: string) => /와일드|wild/i.test(kind.trim());
|
||||
// 공개범위 원문 → 전체(공용) 여부. '전체'/'공용'/'all' 이면 공용, 그 외 개인.
|
||||
const isShared = (scope: string) => /전체|공용|all/i.test(scope.trim());
|
||||
// 카드용도 원문 → CardUsageType 코드. '신규'→NEW '재'→REUSE 그 외 COMMON.
|
||||
function usageCode(usage: string): number {
|
||||
const u = usage.trim();
|
||||
if (/신규/.test(u)) return CardUsageType.NEW;
|
||||
if (/재/.test(u)) return CardUsageType.REUSE;
|
||||
return CardUsageType.COMMON;
|
||||
}
|
||||
|
||||
type CardExcelUploadModalProps = {
|
||||
open: boolean;
|
||||
onConfirm: (rows: CardInput[]) => Promise<{ failures: BulkFailure[] }>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다.
|
||||
// serverErrors: 서버가 거부한 code(카드번호)→사유. 프론트 검증 통과 행에만 마지막에 덧씌운다.
|
||||
function validateRows(rows: RawRow[], serverErrors: Record<string, string>): ValidatedRow[] {
|
||||
return rows.map((row) => {
|
||||
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
|
||||
|
||||
if (!row.code.trim()) return fail('유효성 위반 - 카드번호를 입력해 주십시오.');
|
||||
if (!row.title.trim()) return fail('유효성 위반 - 카드이름을 입력해 주십시오.');
|
||||
if (!row.script.trim()) return fail('유효성 위반 - 스크립트를 입력해 주십시오.');
|
||||
|
||||
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
|
||||
|
||||
const kind = isWild(row.kind) ? '와일드' : '협상';
|
||||
const scope = isShared(row.scope) ? '전체' : '개인';
|
||||
return { ...row, status: '정상', message: `등록 적격 - ${kind}카드 · ${scope}` };
|
||||
});
|
||||
}
|
||||
|
||||
// 검증된(정상) 행 → 서버 등록용 CardInput. 스크립트 평문은 Slate 노드로 복원(변수칩 재현).
|
||||
function toCardInput(row: RawRow): CardInput {
|
||||
const wild = isWild(row.kind);
|
||||
return {
|
||||
title: row.title,
|
||||
code: row.code,
|
||||
editorScript: deserialize(undefined, row.script),
|
||||
status: 'ACTIVE',
|
||||
isWildcard: wild,
|
||||
isShared: isShared(row.scope),
|
||||
usageType: usageCode(row.usage),
|
||||
triggerCondition: wild ? row.condition : undefined,
|
||||
memo: wild ? row.memo : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 카드 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
|
||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||
export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUploadModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [excelFile, setExcelFile] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<RawRow[]>([]);
|
||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validated = useMemo(() => validateRows(rows, serverErrors), [rows, serverErrors]);
|
||||
const validRows = validated.filter((r) => r.status === '정상');
|
||||
const validCount = validRows.length;
|
||||
const errorCount = validated.length - validCount;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const close = () => {
|
||||
setExcelFile(null);
|
||||
setRows([]);
|
||||
setServerErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
||||
const handleFile = async (file: File) => {
|
||||
const parsed = parseCsv(await file.text());
|
||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
||||
id: `row-${i + 1}`,
|
||||
rowNum: i + 2,
|
||||
kind: r['카드종류'] ?? '',
|
||||
code: r['카드번호'] ?? '',
|
||||
title: r['카드이름'] ?? '',
|
||||
script: r['스크립트'] ?? '',
|
||||
usage: r['카드용도'] ?? '',
|
||||
scope: r['공개범위'] ?? '',
|
||||
condition: r['사용조건'] ?? '',
|
||||
memo: r['메모'] ?? '',
|
||||
}));
|
||||
setExcelFile(file.name);
|
||||
setRows(loaded);
|
||||
setServerErrors({});
|
||||
};
|
||||
|
||||
const handleUpdateField = (id: string, field: 'code' | 'title' | 'script', value: string) => {
|
||||
setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row)));
|
||||
};
|
||||
|
||||
const handleRemoveRow = (id: string) => {
|
||||
setRows((cur) => cur.filter((row) => row.id !== id));
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (validRows.length === 0) {
|
||||
showToast('정합성이 무결한 카드 행이 존재하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { failures } = await onConfirm(validRows.map(toCardInput));
|
||||
const okCount = validRows.length - failures.length;
|
||||
if (failures.length === 0) {
|
||||
showToast(`총 ${okCount}개 카드가 서버에 일괄 등록되었습니다.`, 'success');
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// 부분 성공: 등록 성공한 행만 제거하고, 서버가 거부한 행은 사유와 함께 남긴다.
|
||||
const failMap: Record<string, string> = {};
|
||||
failures.forEach((f) => { failMap[f.code] = f.message; });
|
||||
const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined));
|
||||
setServerErrors(failMap);
|
||||
setRows((cur) => cur.filter((r) => !okCodes.has(r.code)));
|
||||
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패`, 'error');
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-6xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<FileSpreadsheet className="text-muted-foreground" size={20} />
|
||||
<Typography variant="h3">협상카드 엑셀 일괄 검증 및 등록</Typography>
|
||||
</div>
|
||||
<button onClick={close} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File select + drop */}
|
||||
<div className="my-6">
|
||||
{!excelFile ? (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
}}
|
||||
className={`border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center transition-colors ${
|
||||
isDragging ? 'border-primary bg-primary/10' : 'border-border bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
<Upload size={32} className="text-muted-foreground mb-3" />
|
||||
<Typography variant="small" className="font-semibold">
|
||||
협상카드 리스트 엑셀 파일을 업로드해 주십시오
|
||||
</Typography>
|
||||
<Typography variant="muted" className="text-[10px] mt-1.5 mb-4">
|
||||
카드번호·카드이름·스크립트 필수 항목 검사를 수행합니다. 스크립트는 평문으로 등록됩니다.
|
||||
</Typography>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="excel-cards-file-input"
|
||||
accept=".csv,.xls,.xlsx"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="py-1.5 px-3 bg-foreground text-background text-xs font-semibold rounded cursor-pointer hover:opacity-95"
|
||||
>
|
||||
엑셀 파일 업로드하기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* File meta */}
|
||||
<div className="flex items-center justify-between p-3 rounded bg-emerald-500/10 border border-emerald-500/30 text-xs">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<CheckCircle2 className="text-emerald-500" size={16} />
|
||||
<span>{excelFile}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{validated.length}개 데이터 행 검출</span>
|
||||
</div>
|
||||
|
||||
{/* Previews table and validation */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-bold text-foreground block">엑셀 정밀 유효값 진단</span>
|
||||
|
||||
<div className="border border-border rounded overflow-auto max-h-60">
|
||||
<Table className="w-full text-left font-mono text-[11px] border-collapse bg-background">
|
||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||
<TableRow>
|
||||
<TableHead className="p-2 font-semibold w-12 text-center">행</TableHead>
|
||||
<TableHead className="p-2 font-semibold text-center w-12">삭제</TableHead>
|
||||
<TableHead className="p-2 font-semibold text-center w-16">자격</TableHead>
|
||||
<TableHead className="p-2 font-semibold">진단 내용</TableHead>
|
||||
<TableHead className="p-2 font-semibold w-16">종류</TableHead>
|
||||
<TableHead className="p-2 font-semibold">카드번호 *</TableHead>
|
||||
<TableHead className="p-2 font-semibold">카드이름 *</TableHead>
|
||||
<TableHead className="p-2 font-semibold">스크립트 *</TableHead>
|
||||
<TableHead className="p-2 font-semibold w-16">용도</TableHead>
|
||||
<TableHead className="p-2 font-semibold w-16">공개</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-border">
|
||||
{validated.map((row) => (
|
||||
<TableRow key={row.id} className={row.status === '오류' ? 'bg-red-500/5 hover:bg-red-500/10' : 'bg-emerald-500/5 hover:bg-emerald-500/10'}>
|
||||
<TableCell className="p-2 text-center text-muted-foreground">{row.rowNum}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRow(row.id)}
|
||||
title="이 행 삭제"
|
||||
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold block ${
|
||||
row.status === '정상'
|
||||
? 'bg-emerald-100 text-emerald-800 border border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/80'
|
||||
: 'bg-red-100 text-red-800 border border-red-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-950'
|
||||
}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={`p-2 font-mono text-[10px] ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
|
||||
{row.message}
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{isWild(row.kind) ? '와일드' : '협상'}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-muted/20 hover:bg-muted/50 text-foreground font-mono"
|
||||
value={row.code}
|
||||
onChange={(e) => handleUpdateField(row.id, 'code', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-muted/20 hover:bg-muted/50 text-foreground font-semibold"
|
||||
value={row.title}
|
||||
onChange={(e) => handleUpdateField(row.id, 'title', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-muted/20 hover:bg-muted/50 text-foreground"
|
||||
value={row.script}
|
||||
onChange={(e) => handleUpdateField(row.id, 'script', e.target.value)}
|
||||
placeholder="협상 스크립트(평문)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{usageCode(row.usage) === CardUsageType.NEW ? '신규' : usageCode(row.usage) === CardUsageType.REUSE ? '재' : '공통'}
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{isShared(row.scope) ? '전체' : '개인'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Typography variant="muted" className="text-[10px]">
|
||||
※ 와일드카드의 사용조건·메모는 표에 표시되지 않지만 업로드에 함께 반영됩니다. 스크립트의 굵게·색상 서식은 평문 등록 후 카드 편집에서 지정하세요.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Excel footer */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border mt-6 text-xs bg-muted/40 p-3 rounded">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
적격 카드: {validCount}개 // 비적격 차단: {errorCount}개
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="py-1.5 px-3 border border-border rounded hover:bg-muted text-foreground cursor-pointer text-xs"
|
||||
>
|
||||
중단
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="excel-cards-confirm-button"
|
||||
disabled={validCount === 0}
|
||||
onClick={handleConfirm}
|
||||
className="py-1.5 px-4 bg-primary text-primary-foreground font-bold rounded hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer text-xs"
|
||||
>
|
||||
적격 카드 등록 (총 {validCount}개)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 2행(협상/와일드). 툴바·모달이 공유한다.
|
||||
export function downloadCardTemplate() {
|
||||
downloadExcel<TemplateRow>(
|
||||
'협상카드_업로드_양식',
|
||||
[
|
||||
{ header: '카드종류', value: (r) => r.kind },
|
||||
{ header: '카드번호', value: (r) => r.code },
|
||||
{ header: '카드이름', value: (r) => r.title },
|
||||
{ header: '스크립트', value: (r) => r.script },
|
||||
{ header: '카드용도', value: (r) => r.usage },
|
||||
{ header: '공개범위', value: (r) => r.scope },
|
||||
{ header: '사용조건', value: (r) => r.condition },
|
||||
{ header: '메모', value: (r) => r.memo },
|
||||
],
|
||||
[
|
||||
{ kind: '협상', code: 'CARD-EX-01', title: '예시) 최우수 등급 부여 카드', script: '귀사를 최우수 협력사로 지정하여 {목표가} 조건을 제안드립니다.', usage: '공통', scope: '개인', condition: '', memo: '' },
|
||||
{ kind: '와일드', code: 'WILD-EX-01', title: '예시) 원자재 급등 대응 카드', script: '원자재 시세 급등에 따른 단가 재조정을 요청드립니다.', usage: '공통', scope: '전체', condition: '원부자재 시세가 계약일 대비 3.5% 상회 시', memo: '특정 원재료 포함 입찰에만 적용' },
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { CardScriptEditor, deserialize, serializeToText } from '../editor';
|
||||
|
||||
const schema = z.object({
|
||||
isWildcard: z.boolean(),
|
||||
isShared: z.boolean(),
|
||||
usageType: z.number(),
|
||||
code: z.string().trim().min(1, '카드번호는 필수 기입 사항입니다.'),
|
||||
title: z.string().trim().min(1, '카드이름은 필수 기입 사항입니다.'),
|
||||
@ -48,6 +49,7 @@ function buildDefaults(
|
||||
if (mode === 'edit' && card) {
|
||||
return {
|
||||
isWildcard: card.isWildcard,
|
||||
isShared: card.isShared,
|
||||
usageType: card.usageType,
|
||||
code: card.code,
|
||||
title: card.title,
|
||||
@ -61,6 +63,7 @@ function buildDefaults(
|
||||
const wild = activeTab === 'WILD';
|
||||
return {
|
||||
isWildcard: wild,
|
||||
isShared: false, // 기본: 개인(나만) — 전체 공용은 등록 시 명시 선택
|
||||
usageType: CardUsageType.COMMON, // 기본: 공통(신규·재 모두)
|
||||
code: generateCardCode(wild),
|
||||
title: '',
|
||||
@ -105,6 +108,7 @@ export function CardFormSheet({
|
||||
editorScript: v.editorScript,
|
||||
status: v.status,
|
||||
isWildcard: v.isWildcard,
|
||||
isShared: v.isShared,
|
||||
usageType: v.usageType,
|
||||
triggerCondition: v.triggerCondition,
|
||||
memo: v.memo,
|
||||
@ -180,6 +184,16 @@ export function CardFormSheet({
|
||||
{errors.code && <p className="text-[10px] text-rose-500">{errors.code.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* 작성자(등록자) — 읽기전용, 편집 시에만. 공용 카드는 작성자 없음. */}
|
||||
{mode === 'edit' && (
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">작성자</Typography>
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">
|
||||
{card?.isShared ? '공용' : card?.creatorName ?? '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold block mb-1">협상 적용 여부</Typography>
|
||||
@ -226,6 +240,35 @@ export function CardFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* 공개 범위(scope): 개인=나만 / 전체=모두 공용(user_id NULL). 등록 시에만 결정, 수정 시 고정 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">공개 범위</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="isShared"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value ? 'ALL' : 'MINE'}
|
||||
onValueChange={(v) => {
|
||||
if (mode !== 'create') return; // 스코프는 등록 시에만 결정(수정 시 이동 불가)
|
||||
field.onChange(v === 'ALL');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="form-card-scope" className="w-full" disabled={mode === 'edit'}>
|
||||
<SelectValue>
|
||||
{(value) => (value === 'ALL' ? '전체 (모두 공용)' : '개인 (나만)')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MINE">개인 (나만)</SelectItem>
|
||||
<SelectItem value="ALL">전체 (모두 공용)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">카드 용도</Typography>
|
||||
@ -248,6 +291,7 @@ export function CardFormSheet({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-1">
|
||||
|
||||
@ -21,8 +21,9 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
||||
{
|
||||
header: '구분',
|
||||
headClassName: 'w-24',
|
||||
cell: (card) =>
|
||||
card.isWildcard ? (
|
||||
cell: (card) => (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{card.isWildcard ? (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-rose-50 text-rose-700 dark:bg-rose-950/20 dark:text-rose-400 border border-rose-200/40">
|
||||
와일드카드
|
||||
</span>
|
||||
@ -30,6 +31,13 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-50 text-blue-700 dark:bg-blue-950/20 dark:text-blue-400 border border-blue-200/40">
|
||||
협상카드
|
||||
</span>
|
||||
)}
|
||||
{card.isShared && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-violet-50 text-violet-700 dark:bg-violet-950/20 dark:text-violet-400 border border-violet-200/40">
|
||||
전체
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -90,6 +98,13 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
||||
<span className="text-muted-foreground font-mono font-medium opacity-65">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '작성자',
|
||||
align: 'center',
|
||||
headClassName: 'w-24',
|
||||
cellClassName: 'text-muted-foreground whitespace-nowrap',
|
||||
cell: (card) => (card.isShared ? '공용' : card.creatorName ?? '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -10,6 +10,7 @@ import type { Descendant } from 'slate';
|
||||
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
|
||||
import type { ResCard } from '@/api/generated/model/resCard';
|
||||
import type { NegotiationCard } from '@/types';
|
||||
import type { BulkFailure } from '@/lib/excel';
|
||||
import { mapCardData, toCardStatusCode } from '../types';
|
||||
import { serializeToMarker } from '../editor';
|
||||
|
||||
@ -21,6 +22,7 @@ export type CardInput = {
|
||||
editorScript: Descendant[];
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
isWildcard: boolean;
|
||||
isShared: boolean; // 전체(공용, user_id NULL) 등록 여부. 등록 시에만 유효(수정 시 스코프 고정)
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
triggerCondition?: string;
|
||||
memo?: string;
|
||||
@ -37,6 +39,7 @@ function cardError(res: ResCard): string | null {
|
||||
function toReq(input: CardInput): ReqCreateCard {
|
||||
return {
|
||||
is_wildcard: input.isWildcard,
|
||||
is_shared: input.isShared,
|
||||
usage_type: input.usageType,
|
||||
name: input.title,
|
||||
number: input.code,
|
||||
@ -73,6 +76,22 @@ export function useCards(params: ListCardsParams) {
|
||||
await refresh();
|
||||
};
|
||||
|
||||
// 엑셀 일괄 등록 — 행마다 createCard(협력사와 동일 패턴). 실패 행은 code(카드번호)로 사유 수집.
|
||||
// N번 refresh 방지 위해 raw createCard 를 직접 돌리고 끝에 한 번만 무효화한다.
|
||||
const bulkCreate = async (rows: CardInput[]): Promise<{ failures: BulkFailure[] }> => {
|
||||
const failures: BulkFailure[] = [];
|
||||
for (const input of rows) {
|
||||
try {
|
||||
const msg = cardError(await createCard(toReq(input)));
|
||||
if (msg) failures.push({ code: input.code, message: msg });
|
||||
} catch (err) {
|
||||
failures.push({ code: input.code, message: err instanceof Error ? err.message : '등록 실패' });
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
return { failures };
|
||||
};
|
||||
|
||||
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
|
||||
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
|
||||
const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션)
|
||||
@ -87,6 +106,7 @@ export function useCards(params: ListCardsParams) {
|
||||
createCard: createCardFn,
|
||||
updateCard: updateCardFn,
|
||||
deleteCard: deleteCardFn,
|
||||
bulkCreate,
|
||||
refresh,
|
||||
cardsQuery,
|
||||
};
|
||||
|
||||
@ -17,6 +17,7 @@ export function mapCardData(c: CardData): NegotiationCard {
|
||||
return {
|
||||
id: c.nego_card_id,
|
||||
isWildcard: c.is_wildcard ?? false,
|
||||
isShared: c.is_shared ?? false,
|
||||
usageType: c.usage_type ?? CardUsageType.COMMON,
|
||||
code: c.number ?? '',
|
||||
title: c.name ?? '',
|
||||
@ -25,6 +26,7 @@ export function mapCardData(c: CardData): NegotiationCard {
|
||||
status: toCardStatusLabel(c.status),
|
||||
triggerCondition: c.condition ?? undefined,
|
||||
memo: c.memo ?? undefined,
|
||||
creatorName: c.creator_name ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { normalizePhone } from '@/lib/phone';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
@ -47,7 +49,7 @@ function buildDefaults(mode: 'create' | 'edit', member: Member | null): FormValu
|
||||
passwordConfirm: '',
|
||||
name: member.name ?? '',
|
||||
email: member.email ?? '',
|
||||
contactNumber: member.contact_number ?? '',
|
||||
contactNumber: normalizePhone(member.contact_number ?? ''),
|
||||
status: member.status,
|
||||
};
|
||||
}
|
||||
@ -216,13 +218,20 @@ export function MemberFormSheet({
|
||||
{/* 연락처 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">연락처</Typography>
|
||||
<Input
|
||||
<Controller
|
||||
control={control}
|
||||
name="contactNumber"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
id="form-member-phone"
|
||||
type="text"
|
||||
{...register('contactNumber')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className={inputClass}
|
||||
placeholder="010-XXXX-XXXX"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { TablePagination } from '@/components/ui/table-pagination';
|
||||
import { USER_ROLE_LABEL } from '@/lib/enumLabels';
|
||||
import { formatPhoneKR } from '@/lib/phone';
|
||||
import { UserStatus, USER_STATUS_LABEL, type Member } from '../types';
|
||||
|
||||
type MemberTableProps = {
|
||||
@ -66,7 +67,7 @@ export function MemberTable({
|
||||
cell: (m) => (
|
||||
<div className="space-y-0.5 font-mono text-xs">
|
||||
<div className="text-foreground">{m.email || '-'}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{m.contact_number || '-'}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{m.contact_number ? formatPhoneKR(m.contact_number) : '-'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
|
||||
@ -76,7 +77,7 @@ function toSupplierCreate(row: RawRow): SupplierCreate {
|
||||
code: row.code,
|
||||
manager_name: row.managerName,
|
||||
manager_email: row.managerEmail,
|
||||
manager_contact_number: '010-0000-0000',
|
||||
manager_contact_number: '01000000000', // 양식에 연락처 컬럼 없음 → placeholder(숫자만 저장 컨벤션)
|
||||
total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
|
||||
};
|
||||
}
|
||||
@ -100,6 +101,7 @@ export function downloadPartnerTemplate() {
|
||||
// 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
|
||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||
export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [excelFile, setExcelFile] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<RawRow[]>([]);
|
||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
||||
@ -199,8 +201,8 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
|
||||
import type { ReqUpdateSupplier as SupplierUpdate } from '@/api/generated/model/reqUpdateSupplier';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { normalizePhone } from '@/lib/phone';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||
import { type Partner } from '../types';
|
||||
|
||||
@ -41,7 +44,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
|
||||
code: partner.code || '',
|
||||
managerName: partner.manager_name || '',
|
||||
managerEmail: partner.manager_email || '',
|
||||
managerPhone: partner.manager_contact_number || '',
|
||||
managerPhone: normalizePhone(partner.manager_contact_number || ''),
|
||||
totalRevenue: partner.total_revenue != null ? String(partner.total_revenue) : '',
|
||||
};
|
||||
}
|
||||
@ -50,7 +53,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa
|
||||
code: `PART-CORP-${Math.floor(100 + Math.random() * 900)}`,
|
||||
managerName: '',
|
||||
managerEmail: '',
|
||||
managerPhone: '010-',
|
||||
managerPhone: '010', // 신규 등록 시 010 프리필 → 뒷자리만 입력
|
||||
totalRevenue: '',
|
||||
};
|
||||
}
|
||||
@ -68,6 +71,7 @@ export function PartnerFormSheet({
|
||||
}: PartnerFormSheetProps) {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
@ -75,6 +79,9 @@ export function PartnerFormSheet({
|
||||
defaultValues: buildDefaults(mode, partner),
|
||||
});
|
||||
|
||||
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
|
||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||
|
||||
const onValid = async (v: FormValues) => {
|
||||
const common = {
|
||||
name: v.name,
|
||||
@ -151,6 +158,14 @@ export function PartnerFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작성자(등록자) — 읽기전용, 편집 시에만 */}
|
||||
{mode === 'edit' && (
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">작성자</Typography>
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">{partner?.creator_name ?? '-'}</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manager Name */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">담당자명</Typography>
|
||||
@ -180,13 +195,20 @@ export function PartnerFormSheet({
|
||||
{/* Manager Phone */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">담당자 연락처</Typography>
|
||||
<Input
|
||||
<Controller
|
||||
control={control}
|
||||
name="managerPhone"
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
id="form-partner-phone"
|
||||
type="text"
|
||||
{...register('managerPhone')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className={inputClass}
|
||||
placeholder="010-XXXX-XXXX"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
||||
</div>
|
||||
|
||||
@ -200,6 +222,8 @@ export function PartnerFormSheet({
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!isSuperAdmin}
|
||||
title={isSuperAdmin ? undefined : '협력사 삭제는 최고관리자만 할 수 있습니다.'}
|
||||
onClick={() => {
|
||||
onDelete(partner.supplier_id, partner.name);
|
||||
onClose();
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { TablePagination } from '@/components/ui/table-pagination';
|
||||
import { formatPhoneKR } from '@/lib/phone';
|
||||
import type { Partner } from '../types';
|
||||
|
||||
type PartnerTableProps = {
|
||||
@ -59,7 +60,7 @@ export function PartnerTable({
|
||||
<div className="space-y-0.5 font-mono text-xs">
|
||||
<div className="text-foreground font-semibold">{part.manager_name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{part.manager_email} / {part.manager_contact_number}
|
||||
{part.manager_email} / {part.manager_contact_number ? formatPhoneKR(part.manager_contact_number) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@ -70,6 +71,12 @@ export function PartnerTable({
|
||||
cellClassName: 'font-mono text-muted-foreground',
|
||||
cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'),
|
||||
},
|
||||
{
|
||||
header: '작성자',
|
||||
align: 'center',
|
||||
cellClassName: 'text-muted-foreground whitespace-nowrap',
|
||||
cell: (part) => part.creator_name ?? '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
|
||||
@ -180,6 +181,7 @@ function toItemCreate(row: RawRow): ItemCreate {
|
||||
// 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고,
|
||||
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
|
||||
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [excelFile, setExcelFile] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<RawRow[]>([]);
|
||||
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
|
||||
@ -294,8 +296,8 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
|
||||
@ -4,6 +4,7 @@ import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import type { Product } from '../types';
|
||||
|
||||
type PriceUpdateModalProps = {
|
||||
@ -17,6 +18,7 @@ type PriceUpdateModalProps = {
|
||||
// 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다.
|
||||
// NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모.
|
||||
export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [isCrawling, setIsCrawling] = useState(false);
|
||||
const [crawlingProgress, setCrawlingProgress] = useState(0);
|
||||
const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]);
|
||||
@ -72,8 +74,8 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 backdrop-blur-xs">
|
||||
<div className="w-full max-w-lg bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono text-xs">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55 backdrop-blur-xs">
|
||||
<div className="w-full max-w-lg bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono text-xs">
|
||||
|
||||
{/* Modal Title */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
|
||||
@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { type Product } from '../types';
|
||||
|
||||
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
|
||||
@ -129,6 +130,13 @@ export function ProductFormSheet({
|
||||
|
||||
const deliveryTypes = DELIVERY_TYPE_OPTIONS;
|
||||
|
||||
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||
const canManageOwn = !!product && (product.user_id === myUserId || isSuperAdmin);
|
||||
// 저장 가능 여부 — 신규는 항상, 수정은 소유자/관리자만.
|
||||
const canSave = mode === 'create' || canManageOwn;
|
||||
|
||||
// minPrice = 인터넷 최저가(실값) → internet_lowest_price 로 저장한다.
|
||||
const onValid = async (v: FormValues) => {
|
||||
// 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여.
|
||||
@ -244,6 +252,14 @@ export function ProductFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작성자(등록자) — 읽기전용, 편집 시에만 */}
|
||||
{mode === 'edit' && (
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">작성자</Typography>
|
||||
<Typography as="p" variant="small" className="text-muted-foreground">{product?.creator_name ?? '-'}</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Price */}
|
||||
<div className="space-y-1">
|
||||
@ -471,6 +487,8 @@ export function ProductFormSheet({
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!canManageOwn}
|
||||
title={canManageOwn ? undefined : '본인이 등록한 상품만 삭제할 수 있습니다.'}
|
||||
onClick={() => {
|
||||
onDelete(product.item_id, product.name);
|
||||
onClose();
|
||||
@ -483,7 +501,12 @@ export function ProductFormSheet({
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isSubmitting || !canSave}
|
||||
title={canSave ? undefined : '본인이 등록한 상품만 수정할 수 있습니다.'}
|
||||
>
|
||||
{mode === 'create' ? '신규 상품 발행' : '변경사항 저장'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -93,6 +93,12 @@ export function ProductTable({
|
||||
cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400',
|
||||
cell: (prod) => (prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'),
|
||||
},
|
||||
{
|
||||
header: '작성자',
|
||||
align: 'center',
|
||||
cellClassName: 'text-muted-foreground whitespace-nowrap',
|
||||
cell: (prod) => prod.creator_name ?? '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
|
||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||
import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
@ -10,14 +9,13 @@ import { mapCardData } from '@/features/cards/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||
import { QuotationType } from '@/api/generated/model';
|
||||
import {
|
||||
supplierTypeOptions,
|
||||
is1v1,
|
||||
toQuotationType,
|
||||
awardStrategySummary,
|
||||
@ -55,6 +53,8 @@ export function QuotationCreateModal({
|
||||
onCreate,
|
||||
onClose,
|
||||
}: QuotationCreateModalProps) {
|
||||
// 모달 열린 동안 배경(부모) 스크롤 잠금 — 뒤 페이지가 같이 스크롤되는 것 방지.
|
||||
useScrollLock();
|
||||
const [step, setStep] = useState(1);
|
||||
const [title, setTitle] = useState('');
|
||||
// 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다.
|
||||
@ -65,9 +65,11 @@ export function QuotationCreateModal({
|
||||
const [dueDate, setDueDate] = useState(nowKstLocalInput);
|
||||
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
|
||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
||||
// 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다.
|
||||
const [partnerDetails, setPartnerDetails] = useState<Map<string, { name: string; email: string }>>(() => new Map());
|
||||
const [cardDetails, setCardDetails] = useState<Map<string, { code: string; title: string; isWildcard: boolean }>>(() => new Map());
|
||||
const [memo, setMemo] = useState('');
|
||||
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
|
||||
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록
|
||||
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@ -81,18 +83,6 @@ export function QuotationCreateModal({
|
||||
: ['기본 정보', '협력사 초청', '확인·완료'];
|
||||
const totalSteps = steps.length;
|
||||
|
||||
// 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
|
||||
const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : '';
|
||||
const lastTypeQuery = useGetSupplierLastType(renegoSupplierId, {
|
||||
query: { enabled: !!renegoSupplierId },
|
||||
});
|
||||
const prevSupplierType = lastTypeQuery.data?.supplier_type ?? null; // 협력사 직전 견적 유형(없으면 null)
|
||||
const prevQtNumber = lastTypeQuery.data?.qt_number ?? '';
|
||||
// 협력사가 정해지면 직전 견적 유형으로 디폴트(이후 사용자가 바꾸면 그 값 유지).
|
||||
useEffect(() => {
|
||||
setSupplierType(prevSupplierType != null ? String(prevSupplierType) : '');
|
||||
}, [renegoSupplierId, prevSupplierType]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출.
|
||||
@ -141,6 +131,12 @@ export function QuotationCreateModal({
|
||||
),
|
||||
}));
|
||||
|
||||
// 선택된 협력사 표시행 — 이름/이메일은 캐시에서, 취급유형은 상품 매핑(supplyTypeBySupplier)에서 라이브로 읽는다.
|
||||
const selectedPartnerRows = selectedPartnerIds.map((id) => {
|
||||
const d = partnerDetails.get(id);
|
||||
return { id, name: d?.name ?? id, email: d?.email ?? '' };
|
||||
});
|
||||
|
||||
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
|
||||
const cardOptions: ComboOption[] = cardRows
|
||||
.filter((c) => !c.isWildcard || c.status === 'ACTIVE')
|
||||
@ -159,6 +155,12 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
|
||||
const selectedCardRows = selectedCardIds.map((id) => {
|
||||
const d = cardDetails.get(id);
|
||||
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
||||
});
|
||||
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
|
||||
const mdNum = Number(mdPrice) || 0;
|
||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
@ -175,7 +177,10 @@ export function QuotationCreateModal({
|
||||
// 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다.
|
||||
if (next === 'auction') setStep((s) => Math.min(s, 3));
|
||||
};
|
||||
const togglePartner = (id: string) =>
|
||||
const togglePartner = (id: string) => {
|
||||
// 담는 순간 이름/이메일을 캐시에 적재 — 이후 검색어가 바뀌어 목록에서 빠져도 선택 테이블이 유지된다.
|
||||
const row = supplierRows.find((r) => r.id === id);
|
||||
if (row) setPartnerDetails((m) => new Map(m).set(id, { name: row.name, email: row.email }));
|
||||
setSelectedPartnerIds((prev) =>
|
||||
oneToOne
|
||||
? prev.includes(id)
|
||||
@ -185,8 +190,12 @@ export function QuotationCreateModal({
|
||||
? prev.filter((p) => p !== id)
|
||||
: [...prev, id],
|
||||
);
|
||||
const toggleCard = (id: string) =>
|
||||
};
|
||||
const toggleCard = (id: string) => {
|
||||
const row = cardRows.find((c) => c.id === id);
|
||||
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
|
||||
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting) return;
|
||||
@ -208,7 +217,6 @@ export function QuotationCreateModal({
|
||||
cardIds: oneToOne ? selectedCardIds : [],
|
||||
memo,
|
||||
mdPrice: mdPrice ? Number(mdPrice) : null,
|
||||
supplierType: supplierType ? Number(supplierType) : null,
|
||||
midAction: oneToOne ? midAction : undefined,
|
||||
overAction: oneToOne ? overAction : undefined,
|
||||
});
|
||||
@ -219,7 +227,7 @@ export function QuotationCreateModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
{submitting && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
|
||||
@ -229,10 +237,10 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 flex flex-col max-h-[90vh] overflow-hidden animate-scale-up font-mono">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="shrink-0 flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<PlusSquare className="text-foreground" size={18} />
|
||||
<Typography variant="small" className="font-bold">신규 견적 등록 (단계 {step}/{totalSteps})</Typography>
|
||||
@ -243,7 +251,7 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
|
||||
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
|
||||
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
|
||||
{steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
return (
|
||||
@ -257,8 +265,8 @@ export function QuotationCreateModal({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="my-6 min-h-60 text-xs text-foreground space-y-4">
|
||||
{/* Step content — 남은 높이를 채우고 내용이 길면 여기만 스크롤(작은 화면 대응) */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 my-6 pr-1 text-xs text-foreground space-y-4">
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
@ -387,7 +395,7 @@ export function QuotationCreateModal({
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography>
|
||||
{/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */}
|
||||
{/* 서버검색 다중선택 — 각 행에 선택 상품 상품조달유형 배지(미매핑=미정). oneToOne이면 togglePartner가 단일로 강제. */}
|
||||
<Combobox
|
||||
variant="inline"
|
||||
multiple
|
||||
@ -400,27 +408,17 @@ export function QuotationCreateModal({
|
||||
emptyText="협력사가 없습니다"
|
||||
maxListHeight="max-h-56"
|
||||
/>
|
||||
|
||||
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
|
||||
<div className="space-y-1 pt-3 border-t border-border/40">
|
||||
<Typography as="label" variant="label">협력사 유형</Typography>
|
||||
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}>
|
||||
<SelectTrigger id="wizard-supplier-type" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supplierTypeOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{prevSupplierType != null && (
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능
|
||||
{/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
|
||||
담긴 협력사 {selectedPartnerRows.length}곳
|
||||
</Typography>
|
||||
)}
|
||||
<SelectedPartnerTable
|
||||
rows={selectedPartnerRows}
|
||||
supplyTypeBySupplier={supplyTypeBySupplier}
|
||||
showSupplyType={!!productId}
|
||||
onRemove={togglePartner}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -514,13 +512,20 @@ export function QuotationCreateModal({
|
||||
emptyText="협상카드가 없습니다"
|
||||
maxListHeight="max-h-72"
|
||||
/>
|
||||
{/* 선택 목록 — 검색어가 바뀌어도 담은 카드가 유지되는 고정 테이블. */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
|
||||
담긴 카드 {selectedCardRows.length}장
|
||||
</Typography>
|
||||
<SelectedCardTable rows={selectedCardRows} onRemove={toggleCard} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer nav */}
|
||||
<div className="flex justify-between items-center pt-4 border-t border-border mt-6">
|
||||
<div className="shrink-0 flex justify-between items-center pt-4 border-t border-border mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -560,12 +565,12 @@ export function QuotationCreateModal({
|
||||
|
||||
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
|
||||
|
||||
// 협력사 취급유형 배지 — type undefined = 이 상품 미취급, 그 외 SupplierType 라벨(제조/유통/총판/없음).
|
||||
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
|
||||
function SupplyTypeBadge({ type }: { type?: number }) {
|
||||
if (type === undefined) {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
미취급
|
||||
미정
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
@ -576,6 +581,108 @@ function SupplyTypeBadge({ type }: { type?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
|
||||
function SelectedPartnerTable({
|
||||
rows,
|
||||
supplyTypeBySupplier,
|
||||
showSupplyType,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: { id: string; name: string; email: string }[];
|
||||
supplyTypeBySupplier: Map<string, number>;
|
||||
showSupplyType: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
담긴 협력사가 없습니다 — 위에서 검색해 담아주세요.
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">협력사명</Typography></th>
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">담당자 이메일</Typography></th>
|
||||
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">상품조달유형</Typography></th>}
|
||||
<th className="w-9 px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border">
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
|
||||
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
|
||||
function SelectedCardTable({
|
||||
rows,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
담긴 카드가 없습니다 — 위에서 검색해 담아주세요.
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded border border-border overflow-hidden">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드번호</Typography></th>
|
||||
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">유형</Typography></th>
|
||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드명</Typography></th>
|
||||
<th className="w-9 px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border">
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||
{r.isWildcard ? '와일드' : '협상'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
||||
function Segmented({
|
||||
options,
|
||||
|
||||
@ -38,6 +38,8 @@ type DrawerHeaderCardsProps = {
|
||||
currentProduct: Product | undefined;
|
||||
/** 목표가 클릭 → 산정내역 모달(대표 세션 기준). 목표가/앵커링가는 견적 단위(1견적=1상품)라 세션 공통값. */
|
||||
onShowTarget: (sessionId: string) => void;
|
||||
/** 접힘 상태 — 상세 그리드는 감추고 상단 결과 요약(목표가·낙찰·절감) 밴드만 남긴다. */
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
export function DrawerHeaderCards({
|
||||
@ -46,7 +48,17 @@ export function DrawerHeaderCards({
|
||||
sessionViews,
|
||||
currentProduct,
|
||||
onShowTarget,
|
||||
collapsed = false,
|
||||
}: DrawerHeaderCardsProps) {
|
||||
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="text-xs font-mono">
|
||||
<ResultSummaryBand quotation={quotation} sessionViews={sessionViews} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Quotations DDL 표시값
|
||||
const q_name = quotation.name || '-';
|
||||
const q_number = quotation.number || '-';
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { X, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { type Partner, sessionStatusLabel } from '../../types';
|
||||
@ -20,6 +21,7 @@ type RegenerateModalProps = {
|
||||
// 마감된 견적의 '다음 라운드'를 만들 때 부를 공급사를 고르는 모달.
|
||||
// 상품·견적번호·협상기간·카드는 원 견적에서 이어받으므로 여기선 공급사만 선택한다.
|
||||
export function RegenerateModal({ open, partners, sessionStatusBySupplier, defaultSupplierIds, onConfirm, onClose }: RegenerateModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [selected, setSelected] = useState<string[]>(defaultSupplierIds);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
if (!open) return null;
|
||||
@ -42,8 +44,8 @@ export function RegenerateModal({ open, partners, sessionStatusBySupplier, defau
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[55] flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 animate-scale-up font-mono">
|
||||
<div className="fixed inset-0 z-[55] flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageSquare, Copy, Mail, MailCheck, Send } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||
import { SessionStatus } from '@/api/generated/model';
|
||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||
import { mapServerSessionView, sessionStatusLabel } from '../../types';
|
||||
|
||||
@ -11,23 +15,63 @@ type SessionView = ReturnType<typeof mapServerSessionView>;
|
||||
export function SessionsStatusTab({
|
||||
sessionViews,
|
||||
canNotify,
|
||||
canAward,
|
||||
winnerSupplierId,
|
||||
onOpenChat,
|
||||
onNotifyAll,
|
||||
onNotifyOne,
|
||||
onAward,
|
||||
}: {
|
||||
sessionViews: SessionView[];
|
||||
/** 초청 메일 발송 권한(견적 소유자만). false 면 발송 버튼 비활성. */
|
||||
canNotify: boolean;
|
||||
/** 직접 낙찰 권한(개찰 상태 + 본인 견적). true 일 때만 낙찰 선택 UI 노출. */
|
||||
canAward: boolean;
|
||||
/** 낙찰 확정된 협력사 id(quotations.preferred_sp_id). 그 행만 초록 배경으로 강조. */
|
||||
winnerSupplierId: string | null;
|
||||
onOpenChat: (sessionId: string) => void;
|
||||
/** 미발송 세션 전체에 초청 메일 발송. */
|
||||
onNotifyAll: () => Promise<void>;
|
||||
/** 한 세션(공급사)에 초청 메일 발송/재발송. */
|
||||
onNotifyOne: (sessionId: string) => Promise<void>;
|
||||
/** 고른 협력사를 낙찰 처리. 성공 시 true. */
|
||||
onAward: (supplierId: string, supplierName: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [sendingAll, setSendingAll] = useState(false);
|
||||
const [sendingId, setSendingId] = useState<string | null>(null);
|
||||
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);
|
||||
const [awarding, setAwarding] = useState(false);
|
||||
const unsentCount = sessionViews.filter((s) => !s.email_sent_at).length;
|
||||
|
||||
// 낙찰 후보 = 투찰한 협상완료(DONE) 협력사. 개찰 견적에서 이 중 하나를 담당자가 직접 낙찰한다.
|
||||
const candidates = sessionViews.filter((s) => s.status === SessionStatus.DONE && s.bid_price != null);
|
||||
const showAward = canAward && candidates.length > 0;
|
||||
// 최저 투찰가 = 자동낙찰과 같은 기준 → 추천 표시(담당자가 동가/사정상 다른 곳을 골라도 됨).
|
||||
const lowestBid = candidates.length ? Math.min(...candidates.map((s) => s.bid_price as number)) : null;
|
||||
const selectedWinner = candidates.find((s) => s.supplier_id === selectedWinnerId) ?? null;
|
||||
const colCount = showAward ? 13 : 12;
|
||||
|
||||
const handleAward = async () => {
|
||||
if (!selectedWinner) return;
|
||||
const name = selectedWinner.supplier_name;
|
||||
const price = selectedWinner.bid_price != null ? `₩${selectedWinner.bid_price.toLocaleString()}` : '-';
|
||||
if (
|
||||
!(await confirm({
|
||||
title: '직접 낙찰',
|
||||
description: `[${name}] (투찰가 ${price})을(를) 낙찰 처리하시겠습니까? 낙찰은 되돌릴 수 없습니다.`,
|
||||
confirmText: '낙찰 확정',
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setAwarding(true);
|
||||
try {
|
||||
const ok = await onAward(selectedWinner.supplier_id, name);
|
||||
if (ok) setSelectedWinnerId(null);
|
||||
} finally {
|
||||
setAwarding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAll = async () => {
|
||||
if (
|
||||
!(await confirm({
|
||||
@ -88,10 +132,30 @@ export function SessionsStatusTab({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 직접 낙찰 툴바 — 개찰(낙찰자 미정) 견적에서만. 표에서 협력사 하나 선택 → 낙찰 확정. */}
|
||||
{showAward && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3">
|
||||
<Typography as="p" variant="small" className="text-[12px] text-warning">
|
||||
개찰 · 낙찰자 미정 — 협력사를 선택해 직접 낙찰하세요.
|
||||
{selectedWinner && <span className="ml-1 font-bold">선택: {selectedWinner.supplier_name}</span>}
|
||||
</Typography>
|
||||
<button
|
||||
onClick={handleAward}
|
||||
disabled={!selectedWinner || awarding}
|
||||
title={selectedWinner ? '선택한 협력사를 낙찰 처리합니다.' : '먼저 낙찰할 협력사를 선택하세요.'}
|
||||
className="flex shrink-0 items-center gap-2 px-3 py-2 bg-success text-white text-xs font-bold rounded hover:bg-success/90 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trophy size={14} />
|
||||
<span>{awarding ? '처리 중…' : '낙찰 확정'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg bg-card overflow-x-auto">
|
||||
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
|
||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||
<TableRow>
|
||||
{showAward && <TableHead className="p-3 font-semibold text-center font-sans w-14">낙찰선택</TableHead>}
|
||||
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
||||
<TableHead className="p-2 font-semibold text-center w-10">URL</TableHead>
|
||||
<TableHead className="p-3 font-semibold text-center font-sans">초청메일</TableHead>
|
||||
@ -109,20 +173,57 @@ export function SessionsStatusTab({
|
||||
<TableBody className="divide-y divide-border">
|
||||
{sessionViews.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
|
||||
<TableCell colSpan={colCount} className="p-12 text-center text-muted-foreground">
|
||||
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{sessionViews.map((sess) => (
|
||||
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
|
||||
{sessionViews.map((sess) => {
|
||||
const isCandidate = sess.status === SessionStatus.DONE && sess.bid_price != null;
|
||||
const isRecommended = isCandidate && sess.bid_price === lowestBid;
|
||||
const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId;
|
||||
return (
|
||||
<TableRow
|
||||
key={sess.session_id}
|
||||
className={`transition-colors text-[11px] ${
|
||||
isWinner ? 'bg-success/10 hover:bg-success/15' : 'hover:bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
{showAward && (
|
||||
<TableCell className="p-3 text-center w-14">
|
||||
{isCandidate ? (
|
||||
<label className="flex flex-col items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="award-winner"
|
||||
checked={selectedWinnerId === sess.supplier_id}
|
||||
onChange={() => setSelectedWinnerId(sess.supplier_id)}
|
||||
className="h-4 w-4 accent-success cursor-pointer"
|
||||
/>
|
||||
{isRecommended && (
|
||||
<Typography as="span" variant="caption" className="text-[9px] font-bold text-success">
|
||||
추천
|
||||
</Typography>
|
||||
)}
|
||||
</label>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="p-3 font-bold text-foreground font-sans">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{sess.supplier_name}</span>
|
||||
<Link
|
||||
to={`/partners?detail=${sess.supplier_id}`}
|
||||
title={`${sess.supplier_name} — 협력사 상세로 이동`}
|
||||
className={cn(typographyVariants({ variant: 'link' }), 'font-bold font-sans truncate')}
|
||||
>
|
||||
{sess.supplier_name}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onOpenChat(sess.session_id)}
|
||||
title="협상 대화방으로 이동"
|
||||
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
|
||||
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
</button>
|
||||
@ -148,7 +249,7 @@ export function SessionsStatusTab({
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{sess.email_sent_at ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 text-[10px] font-semibold">
|
||||
<span className="inline-flex items-center gap-1 text-success text-[10px] font-semibold">
|
||||
<MailCheck size={11} /> 발송됨
|
||||
</span>
|
||||
) : (
|
||||
@ -205,7 +306,8 @@ export function SessionsStatusTab({
|
||||
</TableCell>
|
||||
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { X, Check } from 'lucide-react';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
|
||||
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
|
||||
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
|
||||
@ -12,7 +13,6 @@ type TargetPriceModalProps = {
|
||||
vatYn?: boolean | null;
|
||||
deliveryFeeYn?: boolean | null;
|
||||
category?: string | null;
|
||||
supplierTypeLabel: string;
|
||||
};
|
||||
|
||||
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||
@ -31,18 +31,18 @@ export function TargetPriceModal({
|
||||
vatYn,
|
||||
deliveryFeeYn,
|
||||
category,
|
||||
supplierTypeLabel,
|
||||
}: TargetPriceModalProps) {
|
||||
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
|
||||
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
|
||||
const candidates = bd?.candidates ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-xs"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 font-mono text-xs animate-scale-up"
|
||||
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto font-mono text-xs animate-scale-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
@ -128,7 +128,6 @@ export function TargetPriceModal({
|
||||
{category && (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
|
||||
)}
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {bd.anchoring_value}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
|
||||
앵커링가: {won(bd.anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{bd.anchoring_value})</span>
|
||||
|
||||
@ -19,8 +19,8 @@ import {
|
||||
mapSetting,
|
||||
mapServerSessionView,
|
||||
mapServerCardView,
|
||||
chainRoundState,
|
||||
} from '../../types';
|
||||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||||
import { QuotationStatus } from '@/api/generated/model';
|
||||
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
||||
import { RoundTimeline } from './RoundTimeline';
|
||||
@ -35,6 +35,8 @@ type DrawerTab = 'status' | 'cards' | 'chat';
|
||||
type QuotationDetailSheetProps = {
|
||||
quotation: QuotationData;
|
||||
onCloseQuotation: (id: string, name: string) => void;
|
||||
/** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */
|
||||
onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise<boolean>;
|
||||
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
|
||||
onSwitchRound: (qtId: string) => void;
|
||||
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
|
||||
@ -49,6 +51,7 @@ type QuotationDetailSheetProps = {
|
||||
export function QuotationDetailSheet({
|
||||
quotation,
|
||||
onCloseQuotation,
|
||||
onAward,
|
||||
onSwitchRound,
|
||||
onRegenerate,
|
||||
onNotify,
|
||||
@ -70,9 +73,14 @@ export function QuotationDetailSheet({
|
||||
).map(mapSetting);
|
||||
|
||||
const qtId = quotation.qt_id ?? '';
|
||||
// 초청 메일 발송은 견적 소유자만. (백엔드 스코프 도입 전까지의 1차 차단 — 본인 견적 아니면 버튼 비활성)
|
||||
// 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만.
|
||||
// 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
const canNotify = !!myUserId && quotation.user_id === myUserId;
|
||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||
const canManage = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin);
|
||||
const canNotify = canManage; // 초청 메일 발송/재발송
|
||||
// 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정.
|
||||
const canAward = canManage && chainRoundState(quotation) === 'opened';
|
||||
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
|
||||
// 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다.
|
||||
const sessionsQuery = useGetQuotationSessions(qtId, {
|
||||
@ -93,7 +101,7 @@ export function QuotationDetailSheet({
|
||||
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
|
||||
const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1);
|
||||
const isLatestRound = (quotation.round ?? 1) >= maxRound;
|
||||
const canRegenerate = !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
|
||||
const canRegenerate = canManage && !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
|
||||
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
|
||||
@ -139,8 +147,8 @@ export function QuotationDetailSheet({
|
||||
|
||||
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col shadow-2xl overflow-hidden animate-slide-left">
|
||||
|
||||
{/* Header title bar (고정) */}
|
||||
<div className={`shrink-0 px-6 pt-6 bg-muted/30 ${showHeaderCards ? 'pb-3' : 'pb-6 border-b border-border'}`}>
|
||||
{/* Header title bar (고정) — 아래에 상세 그리드(펼침) 또는 결과 요약 밴드(접힘)가 항상 붙는다. */}
|
||||
<div className="shrink-0 px-6 pt-6 pb-3 bg-muted/30">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
|
||||
@ -169,14 +177,21 @@ export function QuotationDetailSheet({
|
||||
<span>다음 견적 재생성</span>
|
||||
</button>
|
||||
)}
|
||||
{/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */}
|
||||
{/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */}
|
||||
{(() => {
|
||||
const canClose = quotation.status !== QuotationStatus.CLOSED;
|
||||
const alreadyClosed = quotation.status === QuotationStatus.CLOSED;
|
||||
const canClose = canManage && !alreadyClosed;
|
||||
return (
|
||||
<button
|
||||
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
|
||||
disabled={!canClose}
|
||||
title={canClose ? undefined : '이미 마감된 견적입니다.'}
|
||||
title={
|
||||
!canManage
|
||||
? '본인이 생성한 견적만 마감할 수 있습니다.'
|
||||
: alreadyClosed
|
||||
? '이미 마감된 견적입니다.'
|
||||
: undefined
|
||||
}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-600"
|
||||
>
|
||||
<CheckCircle2 size={14} />
|
||||
@ -194,8 +209,8 @@ export function QuotationDetailSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 견적 상세 정보 — 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
|
||||
{showHeaderCards && (
|
||||
{/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
|
||||
{showHeaderCards ? (
|
||||
<div
|
||||
style={{ flex: '2 1 0%' }}
|
||||
className="min-h-0 overflow-y-auto px-6 pb-6 bg-muted/30 border-b border-border"
|
||||
@ -208,6 +223,18 @@ export function QuotationDetailSheet({
|
||||
onShowTarget={setTargetSessionId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* 접어도 결과 요약(목표가·낙찰·절감) 밴드는 상단에 그대로 남긴다. */
|
||||
<div className="shrink-0 px-6 pt-4 pb-4 bg-muted/30 border-b border-border">
|
||||
<DrawerHeaderCards
|
||||
collapsed
|
||||
quotation={quotation}
|
||||
quotationSettings={quotationSettings}
|
||||
sessionViews={sessionViews}
|
||||
currentProduct={currentProduct}
|
||||
onShowTarget={setTargetSessionId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
@ -248,9 +275,12 @@ export function QuotationDetailSheet({
|
||||
<SessionsStatusTab
|
||||
sessionViews={sessionViews}
|
||||
canNotify={canNotify}
|
||||
canAward={canAward}
|
||||
winnerSupplierId={quotation.preferred_sp_id ?? null}
|
||||
onOpenChat={goToChat}
|
||||
onNotifyAll={() => onNotify(qtId)}
|
||||
onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)}
|
||||
onAward={(supplierId, supplierName) => onAward(qtId, supplierId, supplierName)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -283,7 +313,6 @@ export function QuotationDetailSheet({
|
||||
vatYn={currentItem.vat_yn}
|
||||
deliveryFeeYn={currentItem.delivery_fee_yn}
|
||||
category={currentItem.category}
|
||||
supplierTypeLabel={supplierTypeLabel(quotation.supplier_type)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings, X } from 'lucide-react';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -22,6 +23,7 @@ export function QuotationSettingsModal({
|
||||
onDelete,
|
||||
onClose,
|
||||
}: QuotationSettingsModalProps) {
|
||||
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
|
||||
const [targetMargin, setTargetMargin] = useState('');
|
||||
const [cardUseCount, setCardUseCount] = useState('');
|
||||
|
||||
@ -38,8 +40,8 @@ export function QuotationSettingsModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono text-xs">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono text-xs">
|
||||
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
useListQuotations,
|
||||
useCreateQuotation,
|
||||
useStopQuotation,
|
||||
useAwardQuotation,
|
||||
useRegenerateQuotation,
|
||||
useNotifyQuotation,
|
||||
useNotifySession,
|
||||
@ -39,7 +40,6 @@ export type CreateQuotationInput = {
|
||||
cardIds: string[];
|
||||
memo: string;
|
||||
mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정
|
||||
supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
|
||||
// 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN).
|
||||
midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰)
|
||||
overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰)
|
||||
@ -65,6 +65,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
const deleteSettingMutation = useDeleteSetting();
|
||||
const createQuotationMutation = useCreateQuotation();
|
||||
const stopQuotationMutation = useStopQuotation();
|
||||
const awardQuotationMutation = useAwardQuotation();
|
||||
const regenerateQuotationMutation = useRegenerateQuotation();
|
||||
const notifyQuotationMutation = useNotifyQuotation();
|
||||
const notifySessionMutation = useNotifySession();
|
||||
@ -116,6 +117,29 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
);
|
||||
};
|
||||
|
||||
// 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리 → 서버 award_quotation(close_reason→낙찰 + 낙찰자 박제 + 작성자 알림).
|
||||
// 성공 시 단건 견적·세션·알림 목록 재조회로 결과밴드('개찰'→'낙찰')와 알림함을 동기화.
|
||||
const awardQuotation = async (qtId: string, winnerSupplierId: string, winnerName: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await awardQuotationMutation.mutateAsync({ qtId, data: { winner_supplier_id: winnerSupplierId } });
|
||||
if (!res?.result?.success) {
|
||||
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
|
||||
const code = res?.result?.code;
|
||||
showToast(`직접 낙찰 실패${code ? ` [${code}]` : ''}: ${reason}`, 'error');
|
||||
return false;
|
||||
}
|
||||
invalidateQuotations();
|
||||
queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(qtId) });
|
||||
queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(qtId) });
|
||||
queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] });
|
||||
showToast(`[${winnerName}] 협력사를 낙찰 처리했습니다.`, 'success');
|
||||
return true;
|
||||
} catch {
|
||||
showToast('직접 낙찰에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateSettings = () =>
|
||||
queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() });
|
||||
|
||||
@ -194,7 +218,6 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
manager_contact_number: me?.contact || undefined,
|
||||
memo: input.memo.trim() || undefined,
|
||||
md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined,
|
||||
supplier_type: input.supplierType ?? undefined,
|
||||
// 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제.
|
||||
mid_action: input.midAction ?? undefined,
|
||||
over_action: input.overAction ?? undefined,
|
||||
@ -295,6 +318,7 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
total,
|
||||
quotationSettings,
|
||||
closeQuotation,
|
||||
awardQuotation,
|
||||
addSetting,
|
||||
deleteSetting,
|
||||
createQuotation,
|
||||
|
||||
69
negodata/front/src/features/statistics/StatisticsView.tsx
Normal file
69
negodata/front/src/features/statistics/StatisticsView.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { Award, Percent, RefreshCw, Target, TrendingDown, CircleCheckBig } from 'lucide-react';
|
||||
import { Panel } from './components/Panel';
|
||||
import { StatTile } from './components/StatTile';
|
||||
import { SavingsTrendChart } from './components/SavingsTrendChart';
|
||||
import { OutcomeChart } from './components/OutcomeChart';
|
||||
import { ParticipationChart } from './components/ParticipationChart';
|
||||
import { TypeSplitChart } from './components/TypeSplitChart';
|
||||
import { CategoryChart } from './components/CategoryChart';
|
||||
import { CardEffectChart } from './components/CardEffectChart';
|
||||
import { wonCompact, pct, signedWonCompact } from './fmt';
|
||||
import type { StatData } from './types';
|
||||
|
||||
// 통계 본문. KPI 요약 + 절감 분석 + 성사/프로세스 + 카드 효과. scope(회사/내견적)별로 동일 레이아웃.
|
||||
export function StatisticsView({ data }: { data: StatData }) {
|
||||
const k = data.kpi;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 임팩트 요약 KPI */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
<StatTile
|
||||
label="총 절감액 (목표가 대비)"
|
||||
value={wonCompact(k.totalSavings)}
|
||||
icon={TrendingDown}
|
||||
tone="emerald"
|
||||
delta={{ text: `${signedWonCompact(k.savingsDeltaMoM)} 전월비`, good: k.savingsDeltaMoM >= 0 }}
|
||||
/>
|
||||
<StatTile label="평균 절감률" value={pct(k.savingsRate)} icon={Percent} tone="emerald" />
|
||||
<StatTile label="낙찰률" value={pct(k.awardRate)} icon={Award} tone="blue" />
|
||||
<StatTile label="평균 앵커 도달률" value={pct(k.anchorReachRate)} icon={Target} tone="purple" />
|
||||
<StatTile label="마감 견적" value={`${k.closedCount}건`} icon={CircleCheckBig} tone="zinc" />
|
||||
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />
|
||||
</div>
|
||||
|
||||
{/* 절감 분석 */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Panel
|
||||
title="월별 절감 추이"
|
||||
subtitle="목표가 대비 절감액 · 막대에 마우스를 올리면 절감률"
|
||||
className="lg:col-span-2"
|
||||
>
|
||||
<SavingsTrendChart data={data.trend} />
|
||||
</Panel>
|
||||
<Panel title="마감 결과" subtitle="낙찰 vs 개찰 사유 4종">
|
||||
<OutcomeChart data={data.outcome} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* 성사 · 프로세스 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Panel title="협력사 참여" subtitle="초대 세션 대비 응찰·미응찰·거부">
|
||||
<ParticipationChart data={data.participation} />
|
||||
</Panel>
|
||||
<Panel title="유형별 성과" subtitle="협상(1:1) vs 견적(1:N) 낙찰률">
|
||||
<TypeSplitChart data={data.typeSplit} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 · 카드 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Panel title="카테고리별 절감" subtitle="어디서 절감이 났나">
|
||||
<CategoryChart data={data.categories} />
|
||||
</Panel>
|
||||
<Panel title="협상카드 효과" subtitle="유형별 사용빈도 + 사용 직후 평균 제시가 하락">
|
||||
<CardEffectChart data={data.cards} />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
negodata/front/src/features/statistics/api.ts
Normal file
59
negodata/front/src/features/statistics/api.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { useGetStatisticsSummary } from '@/api/generated/statistics/statistics';
|
||||
import type { StatScope as ApiScope } from '@/api/generated/model/statScope';
|
||||
import type { StatData } from './types';
|
||||
|
||||
// 생성 API(snake_case, 전부 optional) → 도메인 StatData(camelCase) 매퍼.
|
||||
// api/generated 는 수정 금지라 여기서 한 번에 흡수한다(mapXxx 관례). 차트 컴포넌트는 StatData만 안다.
|
||||
function mapScope(s?: ApiScope): StatData {
|
||||
const k = s?.kpi ?? {};
|
||||
const o = s?.outcome ?? {};
|
||||
const p = s?.participation ?? {};
|
||||
return {
|
||||
kpi: {
|
||||
totalSavings: k.total_savings ?? 0,
|
||||
savingsRate: k.savings_rate ?? 0,
|
||||
awardRate: k.award_rate ?? 0,
|
||||
anchorReachRate: k.anchor_reach_rate ?? 0,
|
||||
savingsDeltaMoM: k.savings_delta_mom ?? 0,
|
||||
closedCount: k.closed_count ?? 0,
|
||||
regenAvgRound: k.regen_avg_round ?? 0,
|
||||
},
|
||||
trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })),
|
||||
outcome: {
|
||||
awarded: o.awarded ?? 0,
|
||||
openPrice: o.open_price ?? 0,
|
||||
openEqual: o.open_equal ?? 0,
|
||||
openNoshow: o.open_noshow ?? 0,
|
||||
openReject: o.open_reject ?? 0,
|
||||
},
|
||||
participation: {
|
||||
bid: p.bid ?? 0,
|
||||
noParticipate: p.no_participate ?? 0,
|
||||
rejected: p.rejected ?? 0,
|
||||
},
|
||||
typeSplit: (s?.type_split ?? []).map((r) => ({
|
||||
label: r.label,
|
||||
awardRate: r.award_rate ?? 0,
|
||||
avgSavings: r.avg_savings ?? 0,
|
||||
count: r.count ?? 0,
|
||||
})),
|
||||
categories: (s?.categories ?? []).map((c) => ({ category: c.category, savings: c.savings ?? 0, count: c.count ?? 0 })),
|
||||
cards: (s?.cards ?? []).map((c) => ({
|
||||
type: c.type === 'wild' ? 'wild' : 'nego',
|
||||
label: c.label,
|
||||
uses: c.uses ?? 0,
|
||||
avgDrop: c.avg_drop ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// 통계 요약 훅. company/mine 두 스코프를 매핑해 함께 돌려준다.
|
||||
export function useStatistics() {
|
||||
const q = useGetStatisticsSummary();
|
||||
return {
|
||||
isLoading: q.isLoading,
|
||||
isError: q.isError,
|
||||
company: mapScope(q.data?.company),
|
||||
mine: mapScope(q.data?.mine),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SERIES_BG, themeOf, type SeriesKey } from '../palette';
|
||||
import { won } from '../fmt';
|
||||
import type { CardTypeUsage } from '../types';
|
||||
|
||||
// 카드 유형별 사용빈도(막대) + 효과(평균 제시가 하락, avgDrop=B안). 유형별 색.
|
||||
const TONE: Record<CardTypeUsage['type'], SeriesKey> = { nego: 'blue', wild: 'purple' };
|
||||
|
||||
export function CardEffectChart({ data }: { data: CardTypeUsage[] }) {
|
||||
const rows = data.map((d) => ({ ...d, key: d.type, tone: TONE[d.type] }));
|
||||
const config = Object.fromEntries(rows.map((r) => [r.key, { label: r.label, theme: themeOf(r.tone) }])) satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ChartContainer config={config} className="aspect-auto h-44 w-full">
|
||||
<BarChart data={rows} margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} width={32} />
|
||||
<ChartTooltip cursor={false} content={<CardTooltip />} />
|
||||
<Bar dataKey="uses" radius={[4, 4, 0, 0]} maxBarSize={64}>
|
||||
{rows.map((r) => (
|
||||
<Cell key={r.key} fill={`var(--color-${r.key})`} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="flex items-center gap-1.5 rounded border border-border bg-muted/30 px-3 py-1.5">
|
||||
<span className={cn('size-2.5 shrink-0 rounded-[3px]', SERIES_BG[r.tone])} />
|
||||
<div className="min-w-0">
|
||||
<Typography as="p" variant="caption" className="truncate">
|
||||
{r.label} 평균 하락
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="font-mono text-[12px] font-bold text-foreground">
|
||||
{/* TODO: 제시가 하락 델타 미배선 — 백엔드 avg_drop=0 동안 '측정 예정' */}
|
||||
{r.avgDrop > 0 ? won(r.avgDrop) : '측정 예정'}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTooltip({ active, payload }: { active?: boolean; payload?: { payload: CardTypeUsage }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">
|
||||
{p.label}
|
||||
</Typography>
|
||||
<Typography as="p" variant="caption">
|
||||
사용 <span className="font-mono text-foreground">{p.uses.toLocaleString()}회</span>
|
||||
</Typography>
|
||||
{p.avgDrop > 0 && (
|
||||
<Typography as="p" variant="caption">
|
||||
평균 제시가 하락 <span className="font-mono text-foreground">{won(p.avgDrop)}</span>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Bar, BarChart, LabelList, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||
import { themeOf } from '../palette';
|
||||
import { won, wonCompact } from '../fmt';
|
||||
import type { CategorySaving } from '../types';
|
||||
|
||||
// 카테고리별 절감액(가로 막대, 단일 hue=크기). 막대 끝 직접 라벨.
|
||||
const config = {
|
||||
savings: { label: '절감액', theme: themeOf('blue') },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function CategoryChart({ data }: { data: CategorySaving[] }) {
|
||||
const rows = [...data].sort((a, b) => b.savings - a.savings);
|
||||
|
||||
return (
|
||||
<ChartContainer config={config} className="aspect-auto h-56 w-full">
|
||||
<BarChart data={rows} layout="vertical" margin={{ top: 4, right: 44, left: 4, bottom: 0 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis type="category" dataKey="category" tickLine={false} axisLine={false} width={56} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent nameKey="savings" formatter={(value) => `절감액 ${won(Number(value))}`} />}
|
||||
/>
|
||||
<Bar dataKey="savings" fill="var(--color-savings)" radius={[0, 4, 4, 0]} maxBarSize={26}>
|
||||
<LabelList dataKey="savings" position="right" className="fill-foreground" fontSize={11} formatter={(v: number) => wonCompact(v)} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { Cell, Pie, PieChart } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SERIES_BG, themeOf, type SeriesKey } from '../palette';
|
||||
import { pct } from '../fmt';
|
||||
import type { OutcomeBreakdown } from '../types';
|
||||
|
||||
// 마감 결과 분해: 낙찰 + 개찰 4종(도넛). 가운데 낙찰률, 하단 범례+건수.
|
||||
const SLICES: { key: keyof OutcomeBreakdown; label: string; tone: SeriesKey }[] = [
|
||||
{ key: 'awarded', label: '낙찰', tone: 'emerald' },
|
||||
{ key: 'openPrice', label: '개찰·가격', tone: 'amber' },
|
||||
{ key: 'openEqual', label: '개찰·동가', tone: 'purple' },
|
||||
{ key: 'openNoshow', label: '개찰·미응찰', tone: 'zinc' },
|
||||
{ key: 'openReject', label: '개찰·거부', tone: 'rose' },
|
||||
];
|
||||
|
||||
const config = Object.fromEntries(SLICES.map((s) => [s.key, { label: s.label, theme: themeOf(s.tone) }])) satisfies ChartConfig;
|
||||
|
||||
export function OutcomeChart({ data }: { data: OutcomeBreakdown }) {
|
||||
const rows = SLICES.map((s) => ({ ...s, value: data[s.key] }));
|
||||
const total = rows.reduce((sum, r) => sum + r.value, 0) || 1;
|
||||
const awardRate = data.awarded / total;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative mx-auto">
|
||||
<ChartContainer config={config} className="aspect-square h-44">
|
||||
<PieChart>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent nameKey="key" hideLabel />} />
|
||||
<Pie data={rows} dataKey="value" nameKey="key" innerRadius={52} outerRadius={72} strokeWidth={2} paddingAngle={2}>
|
||||
{rows.map((r) => (
|
||||
<Cell key={r.key} fill={`var(--color-${r.key})`} className="stroke-background" />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<Typography variant="h3" className="leading-none">
|
||||
{pct(awardRate, 0)}
|
||||
</Typography>
|
||||
<Typography variant="caption">낙찰률</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="flex items-center gap-1.5">
|
||||
<span className={cn('size-2.5 shrink-0 rounded-[3px]', SERIES_BG[r.tone])} />
|
||||
<Typography as="span" variant="caption" className="flex-1 truncate">
|
||||
{r.label}
|
||||
</Typography>
|
||||
<Typography as="span" variant="caption" className={cn('font-mono', r.tone === 'emerald' && 'text-foreground')}>
|
||||
{r.value}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
negodata/front/src/features/statistics/components/Panel.tsx
Normal file
38
negodata/front/src/features/statistics/components/Panel.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 통계 섹션 카드. 제목/부제 + 우측 슬롯(범례 등) + 본문.
|
||||
export function Panel({
|
||||
title,
|
||||
subtitle,
|
||||
right,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
right?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col gap-3 p-4', className)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Typography variant="h4" className="text-[15px]">
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="caption" className="mt-0.5 block">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import { Bar, BarChart, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SERIES_BG, themeOf, type SeriesKey } from '../palette';
|
||||
import { pct } from '../fmt';
|
||||
import type { ParticipationBreakdown } from '../types';
|
||||
|
||||
// 협력사 참여: 초대 세션 대비 응찰/미응찰/거부(가로 100% 스택). 응찰=참여율.
|
||||
const PARTS: { key: keyof ParticipationBreakdown; label: string; tone: SeriesKey }[] = [
|
||||
{ key: 'bid', label: '응찰', tone: 'emerald' },
|
||||
{ key: 'noParticipate', label: '미응찰', tone: 'zinc' },
|
||||
{ key: 'rejected', label: '거부', tone: 'rose' },
|
||||
];
|
||||
|
||||
const config = Object.fromEntries(PARTS.map((p) => [p.key, { label: p.label, theme: themeOf(p.tone) }])) satisfies ChartConfig;
|
||||
|
||||
export function ParticipationChart({ data }: { data: ParticipationBreakdown }) {
|
||||
const total = PARTS.reduce((sum, p) => sum + data[p.key], 0) || 1;
|
||||
const rate = data.bid / total;
|
||||
const row = [{ name: '세션', ...data }];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Typography variant="h3" className="leading-none">
|
||||
{pct(rate)}
|
||||
</Typography>
|
||||
<Typography variant="caption">응찰 참여율 · 전체 {total.toLocaleString()}건</Typography>
|
||||
</div>
|
||||
|
||||
<ChartContainer config={config} className="aspect-auto h-12 w-full">
|
||||
<BarChart data={row} layout="vertical" margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis type="category" dataKey="name" hide />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="bid" stackId="a" fill="var(--color-bid)" radius={[6, 0, 0, 6]} />
|
||||
<Bar dataKey="noParticipate" stackId="a" fill="var(--color-noParticipate)" />
|
||||
<Bar dataKey="rejected" stackId="a" fill="var(--color-rejected)" radius={[0, 6, 6, 0]} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{PARTS.map((p) => (
|
||||
<div key={p.key} className="flex items-center gap-1.5">
|
||||
<span className={cn('size-2.5 shrink-0 rounded-[3px]', SERIES_BG[p.tone])} />
|
||||
<Typography as="span" variant="caption">
|
||||
{p.label}
|
||||
</Typography>
|
||||
<Typography as="span" variant="caption" className="font-mono text-foreground">
|
||||
{data[p.key].toLocaleString()}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { themeOf } from '../palette';
|
||||
import { won, wonCompact, pct, monthLabel } from '../fmt';
|
||||
import type { MonthPoint } from '../types';
|
||||
|
||||
// 월별 목표가 대비 절감액(단일 시리즈=크기). 한 축 원칙 — 절감률은 이중축 대신 툴팁에 얹는다.
|
||||
const config = {
|
||||
savings: { label: '절감액', theme: themeOf('emerald') },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function SavingsTrendChart({ data }: { data: MonthPoint[] }) {
|
||||
return (
|
||||
<ChartContainer config={config} className="aspect-auto h-56 w-full">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={monthLabel} />
|
||||
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={(v) => wonCompact(Number(v))} />
|
||||
<ChartTooltip cursor={false} content={<TrendTooltip />} />
|
||||
<Bar dataKey="savings" fill="var(--color-savings)" radius={[4, 4, 0, 0]} maxBarSize={48} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// 절감액(₩) + 절감률을 한 툴팁에. payload[0].payload = MonthPoint.
|
||||
function TrendTooltip({ active, payload }: { active?: boolean; payload?: { payload: MonthPoint }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">
|
||||
{monthLabel(p.month)}
|
||||
</Typography>
|
||||
<Typography as="p" variant="caption" className="font-mono text-foreground">
|
||||
{won(p.savings)} · {pct(p.rate)}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user