conflict 수정.

This commit is contained in:
hbyang 2026-07-07 17:18:54 +09:00
commit a2731d97f5
118 changed files with 4168 additions and 492 deletions

View File

@ -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 = (

View File

@ -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)

View File

@ -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, (기존 공급가현재가)/기존 공급가)
제시가부터 기존 공급가 대비 인하가 반영되므로 라운드도 실값. 기존 공급가 없으면 제시가 기준.

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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, "

View File

@ -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

View 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

View File

@ -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)

View File

@ -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 # 유통

View File

@ -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, {}

View File

@ -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, {}

View File

@ -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:

View 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, []

View File

@ -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, {}

View File

@ -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)

View File

@ -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

View File

@ -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="최저가 수집 요청(스텁)")

View File

@ -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

View File

@ -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

View File

@ -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))
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----

View 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 # 총 절감액(목표가 대비, 낙찰세션 targetbid 합)
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 추가 스코프)

View 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))

View File

@ -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

View File

@ -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)))

View File

@ -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

View File

@ -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,

View File

@ -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()],

View File

@ -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
if value is None:
value = get_base_anchoring_value(price_range)
ap = calc_anchoring_price(tp, value) # 목표가×(1000value)//1000 — float 곱셈 금지(1원 내림 정확성)
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) # 목표가×(1000value)//1000 — float 곱셈 금지(1원 내림 정확성)
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

View 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

View File

@ -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:

View 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)

View 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())

View File

@ -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()

View 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())

View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ResLastSupplierTypeMsg = string | null;
export type CardDataCreatorName = string | null;

View File

@ -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';
@ -366,4 +374,4 @@ export * from './userRole';
export * from './userStatus';
export * from './validationError';
export * from './validationErrorCtx';
export * from './validationErrorLocItem';
export * from './validationErrorLocItem';

View File

@ -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;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ResLastSupplierTypeQtNumber = string | null;
export type ItemDataCreatorName = string | null;

View File

@ -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;

View File

@ -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;
}

View File

@ -13,6 +13,7 @@ import type { ReqCreateCardMemo } from './reqCreateCardMemo';
export interface ReqCreateCard {
is_wildcard?: boolean;
is_shared?: boolean;
name?: ReqCreateCardName;
number?: ReqCreateCardNumber;
script?: ReqCreateCardScript;

View File

@ -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[];

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateQuotationSupplierType = number | null;
export type ResStatisticsSummaryMsg = string | null;

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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[];
}

View 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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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];

View File

@ -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>>

View 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;
}

View File

@ -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},

View File

@ -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',

View File

@ -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: '견적관리',

View 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,
};

View 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}
/>
);
});

View File

@ -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>
{/* 비밀번호 변경(옵션) */}

View File

@ -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: '특정 원재료 포함 입찰에만 적용' },
],
);
}

View File

@ -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,27 +240,57 @@ export function CardFormSheet({
</div>
</div>
{/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */}
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold"> </Typography>
<Controller
control={control}
name="usageType"
render={({ field }) => (
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}>
<SelectTrigger id="form-card-usage-type" className="w-full">
<SelectValue>
{(value) => CARD_USAGE_TYPE_LABEL[Number(value) as CardUsageType] ?? '공통'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CARD_USAGE_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<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>
<Controller
control={control}
name="usageType"
render={({ field }) => (
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}>
<SelectTrigger id="form-card-usage-type" className="w-full">
<SelectValue>
{(value) => CARD_USAGE_TYPE_LABEL[Number(value) as CardUsageType] ?? '공통'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CARD_USAGE_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
{/* Title */}

View File

@ -21,16 +21,24 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
{
header: '구분',
headClassName: 'w-24',
cell: (card) =>
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>
) : (
<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>
),
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>
) : (
<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>
),
},
{
header: '카드번호',
@ -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 ?? '-'),
},
]}
/>
);

View File

@ -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,
};

View File

@ -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,
};
}

View File

@ -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,12 +218,19 @@ export function MemberFormSheet({
{/* 연락처 */}
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Input
id="form-member-phone"
type="text"
{...register('contactNumber')}
className={inputClass}
placeholder="010-XXXX-XXXX"
<Controller
control={control}
name="contactNumber"
render={({ field }) => (
<PhoneInput
id="form-member-phone"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className={inputClass}
placeholder="010-XXXX-XXXX"
/>
)}
/>
</div>

View File

@ -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>
),
},

View File

@ -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">

View File

@ -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,12 +195,19 @@ export function PartnerFormSheet({
{/* Manager Phone */}
<div className="space-y-1">
<Typography as="label" variant="label"> </Typography>
<Input
id="form-partner-phone"
type="text"
{...register('managerPhone')}
className={inputClass}
placeholder="010-XXXX-XXXX"
<Controller
control={control}
name="managerPhone"
render={({ field }) => (
<PhoneInput
id="form-partner-phone"
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();

View File

@ -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 ?? '-',
},
]}
/>
);

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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 ?? '-',
},
]}
/>
);

View File

@ -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}) ·
</Typography>
)}
{/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */}
<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,

View File

@ -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 || '-';

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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)}
/>
);
})()}

View File

@ -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">

View File

@ -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,

View 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>
);
}

View 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),
};
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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