Merge remote-tracking branch 'origin/main' into feat/source-state
This commit is contained in:
commit
67612b477c
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,3 +31,4 @@ CLAUDE.md
|
||||
|
||||
/mobile.mov
|
||||
.gstack/
|
||||
.playwright-mcp/
|
||||
|
||||
BIN
0729~30_테스트.xlsx
Normal file
BIN
0729~30_테스트.xlsx
Normal file
Binary file not shown.
BIN
0803_가격협상 우선 적용 및 논의 정리.xlsx
Normal file
BIN
0803_가격협상 우선 적용 및 논의 정리.xlsx
Normal file
Binary file not shown.
BIN
260727_AIO2O 테스트 및 요청사항.xlsx
Normal file
BIN
260727_AIO2O 테스트 및 요청사항.xlsx
Normal file
Binary file not shown.
@ -36,26 +36,26 @@ _QUOTATION_SETTINGS = table(
|
||||
)
|
||||
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("purchase_price"),
|
||||
column("company_id"), column("internet_lowest_price"), column("deleted"), schema="partner")
|
||||
# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼과 그 호칭을 여기서 정한다.
|
||||
# 고객사 설정(companies.settings) — 협상 기준가로 쓸 가격 컬럼을 여기서 정한다.
|
||||
_COMPANIES = table("companies", column("company_id"), column("settings"), column("deleted"), schema="company")
|
||||
|
||||
# 협상 기준가 후보: items 컬럼 ↔ 용어 카탈로그 키 ↔ 용어 미설정 시 기본값.
|
||||
# 기본값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 한다 — 화면 라벨과
|
||||
# 협상 멘트 호칭이 갈리지 않도록. 문장이 어색하면 회사가 용어 탭에서 바꾼다.
|
||||
_BASELINE_PRICE = ("price", "item.price", "상품 단가")
|
||||
_BASELINE_PURCHASE = ("purchase_price", "item.purchase_price", "매입가")
|
||||
_BASELINE_BY_FIELD = {"price": _BASELINE_PRICE, "purchase_price": _BASELINE_PURCHASE}
|
||||
# 협상 기준가 후보 컬럼. 어느 컬럼을 고르든 공급사 화면 호칭은 '공급가'로 고정한다 —
|
||||
# 같은 돈을 고객사는 매입가·상품 단가 등으로 부르지만 챗은 공급사가 보는 화면이라
|
||||
# 공급사 관점 용어 하나만 쓴다. 회사 용어 사전(labels)은 관리자 화면 전용.
|
||||
_BASELINE_PRICE = "price"
|
||||
_BASELINE_PURCHASE = "purchase_price"
|
||||
_SUPPLIER_PRICE_LABEL = "공급가"
|
||||
|
||||
|
||||
def _resolve_baseline(settings: dict) -> tuple:
|
||||
"""회사 설정 → 협상 기준가로 쓸 (컬럼, 라벨키, 호칭 폴백).
|
||||
def _resolve_baseline(settings: dict) -> str:
|
||||
"""회사 설정 → 협상 기준가로 쓸 items 컬럼명.
|
||||
|
||||
1순위는 관리자가 회사 설정에서 고른 값(features.nego_baseline_field).
|
||||
미설정 회사는 공급가가 기본이되, 공급가를 화면에서 감췄다면 그 회사는 공급가를 관리하지
|
||||
않는다는 뜻이므로 매입가로 폴백한다 — 설정 화면이 생기기 전에 만들어진 회사를 위한 안전망."""
|
||||
chosen = (settings.get("features") or {}).get("nego_baseline_field")
|
||||
if chosen in _BASELINE_BY_FIELD:
|
||||
return _BASELINE_BY_FIELD[chosen]
|
||||
if chosen in (_BASELINE_PRICE, _BASELINE_PURCHASE):
|
||||
return chosen
|
||||
hidden = set(settings.get("hidden_fields") or [])
|
||||
if "price" in hidden and "purchase_price" not in hidden:
|
||||
return _BASELINE_PURCHASE
|
||||
@ -178,7 +178,7 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_item_baseline(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Tuple[int, str, dict]]:
|
||||
_fallback = (0, _BASELINE_PRICE[2], {})
|
||||
_fallback = (0, _SUPPLIER_PRICE_LABEL, {})
|
||||
try:
|
||||
# 상품 + 소속 고객사 설정 한 번에. 회사가 없어도(데이터 이상) 상품 행은 나오도록 outer join.
|
||||
query = (
|
||||
@ -194,10 +194,9 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
price, purchase_price, settings = rows[0]
|
||||
settings = settings if isinstance(settings, dict) else {}
|
||||
labels = settings.get("labels") or {}
|
||||
field, label_key, label_fallback = _resolve_baseline(settings)
|
||||
label = labels.get(label_key) or label_fallback
|
||||
value = purchase_price if field == "purchase_price" else price
|
||||
return ErrorType.SUCCESS, (int(value or 0), label, labels)
|
||||
field = _resolve_baseline(settings)
|
||||
value = purchase_price if field == _BASELINE_PURCHASE else price
|
||||
return ErrorType.SUCCESS, (int(value or 0), _SUPPLIER_PRICE_LABEL, labels)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, _fallback
|
||||
|
||||
@ -55,10 +55,10 @@ _SCRIPT_LABELS = {
|
||||
"label_delivery_type_2": ("delivery_type.2", "지정택배배송"),
|
||||
"label_delivery_type_3": ("delivery_type.3", "픽업배송"),
|
||||
"label_product": ("item.name", "상품명"),
|
||||
# 협상 기준가 호칭의 최후 폴백. 실제 값은 loader 가 회사 설정에서 정해 컨텍스트에 박제하고,
|
||||
# 이 값은 DB 컨텍스트가 없는 데모/직접호출 경로에서만 쓰인다.
|
||||
"label_item_price": ("item.price", "상품 단가"),
|
||||
}
|
||||
# 협상 기준가 호칭 — 공급사 화면 고정 용어. 회사 용어 사전(labels)을 타지 않는다(그건 관리자 화면 전용).
|
||||
# 실제 값은 loader 가 컨텍스트에 박제하고, DB 컨텍스트가 없는 데모/직접호출 경로만 이 폴백을 쓴다.
|
||||
_SUPPLIER_PRICE_LABEL = "공급가"
|
||||
# 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {label_supplier_를} 처럼 대표형을 적는다.
|
||||
# 회사가 바꾼 용어의 받침을 예측할 수 없어 스크립트에 조사를 고정할 수 없다("협력사를"/"공급업체을").
|
||||
_JOSA = {"은": ("은", "는"), "는": ("은", "는"), "이": ("이", "가"), "가": ("이", "가"),
|
||||
@ -320,6 +320,11 @@ class ChatEngine:
|
||||
out[token] = word
|
||||
for form in ("는", "가", "를", "와"):
|
||||
out[f"{token}_{form}"] = _josa(word, form)
|
||||
# 기준가 호칭은 회사 용어가 아니라 공급사 관점 고정 — loader 박제값(없으면 '공급가').
|
||||
price_word = str(ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL)
|
||||
out["label_item_price"] = price_word
|
||||
for form in ("는", "가", "를", "와"):
|
||||
out[f"label_item_price_{form}"] = _josa(price_word, form)
|
||||
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
|
||||
if ctx.get("partner_name"):
|
||||
out["partner_name"] = str(ctx["partner_name"])
|
||||
@ -359,9 +364,9 @@ class ChatEngine:
|
||||
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
||||
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
|
||||
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
|
||||
# 기준가 호칭(공급가/매입가/회사 라벨)은 회사 설정에서 온다 — loader 가 박제한 값.
|
||||
# 기준가 호칭은 공급사 화면 고정 용어('공급가') — loader 가 박제한 값.
|
||||
base = ctx.get("item_price") or 0
|
||||
label = ctx.get("item_price_label") or _SCRIPT_LABELS["label_item_price"][1]
|
||||
label = ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL
|
||||
if base > 1 and "input_price" in ctx:
|
||||
rate = ((base - ctx["input_price"]) / base) * 100
|
||||
out["discount_rate"] = f"{max(0.0, rate):.1f}"
|
||||
|
||||
@ -41,7 +41,7 @@ _DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_reven
|
||||
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
|
||||
_DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name})
|
||||
_DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name})
|
||||
_DEFAULT_ITEM_PRICE_LABEL = "상품 단가" # 협상 기준가 호칭 — DB 컨텍스트 없는 데모/직접호출 경로 폴백
|
||||
_DEFAULT_ITEM_PRICE_LABEL = "공급가" # 협상 기준가 호칭(공급사 화면 고정 용어) — DB 컨텍스트 없는 데모/직접호출 경로 폴백
|
||||
# (negodata 용어 카탈로그 item.price 의 base 와 같아야 표기가 갈리지 않는다)
|
||||
|
||||
|
||||
|
||||
@ -191,8 +191,9 @@ async def test_loader_with_crud_double(db_engine):
|
||||
return ErrorType.SUCCESS, (2, 50000, None, 52500, uuid.uuid4(), uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
async def get_item_baseline(self, cdb, item_id):
|
||||
# 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전
|
||||
return ErrorType.SUCCESS, (7000, "매입가", {"supplier": "공급업체"})
|
||||
# 기준가를 매입가로 고른 회사 + 거래상대 호칭을 '공급업체'로 바꾼 용어 사전.
|
||||
# 호칭은 crud 가 어떤 회사든 '공급가'(공급사 화면 고정 용어)로 내려준다.
|
||||
return ErrorType.SUCCESS, (7000, "공급가", {"supplier": "공급업체"})
|
||||
|
||||
async def get_item_lowest_price(self, cdb, item_id):
|
||||
return ErrorType.SUCCESS, 6300 # 인터넷 최저가(items.internet_lowest_price)
|
||||
@ -233,7 +234,7 @@ async def test_loader_with_crud_double(db_engine):
|
||||
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
|
||||
assert ctx.done_ceiling_price == 52500 # 타결 상한가 박제값(목표가 +5%)
|
||||
assert ctx.item_price == 7000
|
||||
assert ctx.item_price_label == "매입가" # 기준가 호칭이 멘트까지 전달되는지
|
||||
assert ctx.item_price_label == "공급가" # 기준가 호칭이 멘트까지 전달되는지
|
||||
assert ctx.labels == {"supplier": "공급업체"} # 회사 용어 사전이 스크립트 토큰용으로 실리는지
|
||||
assert ctx.internet_lowest_price == 6300 # 인터넷 최저가 로드 확인
|
||||
assert ctx.card_count == 3 # 협상카드 사용 횟수 상한 로드 확인
|
||||
|
||||
@ -72,5 +72,4 @@ class Res_HidePopup(Res_WebPacketProtocol):
|
||||
class Res_SessionBranding(Res_WebPacketProtocol):
|
||||
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
|
||||
logo_url: str = Field("", description="회사 로고 URL")
|
||||
primary_color: str = Field("", description="브랜드 색상(hex)")
|
||||
helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")
|
||||
|
||||
@ -279,7 +279,6 @@ class AuthService:
|
||||
branding = branding or {}
|
||||
res.service_name = branding.get("service_name") or ""
|
||||
res.logo_url = branding.get("logo_url") or ""
|
||||
res.primary_color = branding.get("primary_color") or ""
|
||||
res.helpdesk = branding.get("helpdesk") or []
|
||||
return res
|
||||
|
||||
|
||||
@ -262,14 +262,18 @@ class ChatService:
|
||||
)
|
||||
res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||
|
||||
# 회사가 VAT(vat_yn)를 관리하지 않으면(hidden_fields) 협상 화면 VAT 표기를 숨긴다(값 null → 프론트 라벨 생략).
|
||||
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
|
||||
if "vat_yn" in _hidden:
|
||||
_features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||
|
||||
# VAT 표기 — 부가세 전체 통일 회사(features.vat_mode)는 상품 잔존값과 무관하게 'VAT 별도' 고정(False).
|
||||
# 상품별 관리 회사가 vat_yn 을 숨겼으면(구 방식) 표기 자체를 생략한다(값 null → 프론트 라벨 생략).
|
||||
if _features.get("vat_mode") == "unified_excluded":
|
||||
res.item_vat_yn = False
|
||||
elif "vat_yn" in _hidden:
|
||||
res.item_vat_yn = None
|
||||
|
||||
# 협상 기준가 — 회사 설정에서 고른 가격 컬럼(features.nego_baseline_field).
|
||||
# agent 의 인하율 멘트(nego_context_crud._resolve_baseline)와 같은 규칙이어야 화면과 멘트가 어긋나지 않는다.
|
||||
_features = (settings.get("features") or {}) if _e == ErrorType.SUCCESS and settings else {}
|
||||
_baseline = _features.get("nego_baseline_field")
|
||||
if _baseline not in ("price", "purchase_price"):
|
||||
# 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻.
|
||||
|
||||
@ -226,6 +226,35 @@ async def test_chat_init_forbidden_other_supplier(client, chat_seed):
|
||||
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
||||
|
||||
|
||||
async def test_chat_init_vat_mode_unified_shows_excluded(client, chat_seed, db_engine):
|
||||
"""검증: 부가세 전체 통일 회사(features.vat_mode=unified_excluded)의 세션 채팅 init.
|
||||
기대결과: 상품에 vat_yn=true 잔존값이 있어도 item_vat_yn=False — 프론트가 'VAT별도'로 고정 표기."""
|
||||
import json
|
||||
|
||||
company_id = uuid.uuid4()
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO company.companies (company_id, name, status, settings) VALUES (:c, :n, 1, CAST(:s AS JSONB))"),
|
||||
{"c": company_id, "n": f"{MARK}VAT통일사", "s": json.dumps({"features": {"vat_mode": "unified_excluded"}})},
|
||||
)
|
||||
await conn.execute(
|
||||
text("UPDATE partner.suppliers SET company_id = :c WHERE supplier_id = :sid"),
|
||||
{"c": company_id, "sid": chat_seed["supplier_id"]},
|
||||
)
|
||||
await conn.execute(
|
||||
text("UPDATE partner.items SET vat_yn = true WHERE item_id = (SELECT item_id FROM negotiation.sessions WHERE session_id = :s)"),
|
||||
{"s": chat_seed["sids"]["P"]},
|
||||
)
|
||||
try:
|
||||
token = await _login_token(client)
|
||||
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["item_vat_yn"] is False
|
||||
finally:
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("DELETE FROM company.companies WHERE company_id = :c"), {"c": company_id})
|
||||
|
||||
|
||||
# ---- messages (오프닝 seed) -------------------------------------------------
|
||||
async def test_messages_seeds_opening(client, chat_seed):
|
||||
token = await _login_token(client)
|
||||
|
||||
BIN
dev-settings.png
Normal file
BIN
dev-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/AIO2O-요청사항-반영보고서.pdf
Normal file
BIN
docs/AIO2O-요청사항-반영보고서.pdf
Normal file
Binary file not shown.
157
docs/aio2o-request-implementation-report.swift
Normal file
157
docs/aio2o-request-implementation-report.swift
Normal file
@ -0,0 +1,157 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
let output = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "docs/AIO2O-요청사항-반영보고서.pdf"
|
||||
let W: CGFloat = 595, H: CGFloat = 842, M: CGFloat = 42
|
||||
let navy = NSColor(calibratedRed: 0.06, green: 0.08, blue: 0.16, alpha: 1)
|
||||
let ink = NSColor(calibratedRed: 0.11, green: 0.13, blue: 0.18, alpha: 1)
|
||||
let muted = NSColor(calibratedRed: 0.39, green: 0.43, blue: 0.50, alpha: 1)
|
||||
let paper = NSColor(calibratedRed: 0.98, green: 0.985, blue: 0.995, alpha: 1)
|
||||
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
|
||||
let purple = NSColor(calibratedRed: 0.48, green: 0.25, blue: 0.92, alpha: 1)
|
||||
let green = NSColor(calibratedRed: 0.08, green: 0.60, blue: 0.37, alpha: 1)
|
||||
let orange = NSColor(calibratedRed: 0.94, green: 0.48, blue: 0.10, alpha: 1)
|
||||
let red = NSColor(calibratedRed: 0.85, green: 0.24, blue: 0.28, alpha: 1)
|
||||
let blue = NSColor(calibratedRed: 0.13, green: 0.39, blue: 0.92, alpha: 1)
|
||||
|
||||
func pr(_ r: CGRect) -> CGRect { CGRect(x: r.minX, y: H-r.maxY, width: r.width, height: r.height) }
|
||||
func font(_ s: CGFloat, _ w: NSFont.Weight = .regular) -> NSFont {
|
||||
NSFont(name: "Apple SD Gothic Neo", size: s) ?? .systemFont(ofSize: s, weight: w)
|
||||
}
|
||||
func style(_ s: CGFloat, _ c: NSColor = ink, _ w: NSFont.Weight = .regular,
|
||||
_ a: NSTextAlignment = .left, _ spacing: CGFloat = 2.5) -> [NSAttributedString.Key:Any] {
|
||||
let p = NSMutableParagraphStyle(); p.alignment = a; p.lineSpacing = spacing; p.lineBreakMode = .byWordWrapping
|
||||
return [.font:font(s,w), .foregroundColor:c, .paragraphStyle:p]
|
||||
}
|
||||
func text(_ t:String,_ r:CGRect,_ s:CGFloat=10,_ c:NSColor=ink,_ w:NSFont.Weight = .regular,
|
||||
_ a:NSTextAlignment = .left,_ spacing:CGFloat=2.5) {
|
||||
NSAttributedString(string:t,attributes:style(s,c,w,a,spacing)).draw(with:pr(r),options:[.usesLineFragmentOrigin,.usesFontLeading])
|
||||
}
|
||||
func box(_ r:CGRect,_ fill:NSColor = .white,_ stroke:NSColor? = line,_ radius:CGFloat=10) {
|
||||
let p=NSBezierPath(roundedRect:pr(r),xRadius:radius,yRadius:radius); fill.setFill(); p.fill()
|
||||
if let stroke { stroke.setStroke(); p.lineWidth=0.8; p.stroke() }
|
||||
}
|
||||
func pill(_ t:String,_ r:CGRect,_ c:NSColor) {
|
||||
box(r,c.withAlphaComponent(0.12),nil,r.height/2)
|
||||
text(t,CGRect(x:r.minX,y:r.minY+4,width:r.width,height:r.height-7),8.2,c,.semibold,.center,1)
|
||||
}
|
||||
func begin(_ ctx:CGContext,_ page:Int,_ title:String) {
|
||||
ctx.beginPDFPage(nil); ctx.saveGState(); NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current=NSGraphicsContext(cgContext:ctx,flipped:false)
|
||||
paper.setFill(); NSBezierPath(rect:pr(CGRect(x:0,y:0,width:W,height:H))).fill()
|
||||
text(title,CGRect(x:M,y:30,width:420,height:16),7.5,muted,.medium)
|
||||
text(String(format:"%02d",page),CGRect(x:W-M-30,y:30,width:30,height:16),8,muted,.medium,.right)
|
||||
let p=NSBezierPath(); p.move(to:CGPoint(x:M,y:31)); p.line(to:CGPoint(x:W-M,y:31))
|
||||
line.setStroke(); p.lineWidth=0.7; p.stroke()
|
||||
}
|
||||
func end(_ ctx:CGContext) {
|
||||
NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); ctx.endPDFPage()
|
||||
}
|
||||
func heading(_ n:String,_ t:String,_ sub:String) {
|
||||
pill(n,CGRect(x:M,y:54,width:34,height:24),purple)
|
||||
text(t,CGRect(x:86,y:49,width:465,height:30),21,navy,.bold)
|
||||
text(sub,CGRect(x:M,y:87,width:W-2*M,height:31),9.5,muted,.regular,.left,3)
|
||||
}
|
||||
func statusRow(_ no:String,_ title:String,_ body:String,_ status:String,_ c:NSColor,_ y:CGFloat,_ h:CGFloat=82) {
|
||||
box(CGRect(x:M,y:y,width:W-2*M,height:h),.white,line,9)
|
||||
pill(no,CGRect(x:M+12,y:y+13,width:28,height:20),c)
|
||||
text(title,CGRect(x:M+50,y:y+12,width:338,height:19),10.5,navy,.bold)
|
||||
pill(status,CGRect(x:W-M-102,y:y+12,width:90,height:21),c)
|
||||
text(body,CGRect(x:M+50,y:y+37,width:W-2*M-64,height:h-44),8.8,ink,.regular,.left,2.4)
|
||||
}
|
||||
func metric(_ value:String,_ label:String,_ x:CGFloat,_ c:NSColor) {
|
||||
box(CGRect(x:x,y:435,width:117,height:96),c.withAlphaComponent(0.08),c.withAlphaComponent(0.3),12)
|
||||
text(value,CGRect(x:x+8,y:454,width:101,height:32),25,c,.bold,.center)
|
||||
text(label,CGRect(x:x+8,y:493,width:101,height:20),9,muted,.medium,.center)
|
||||
}
|
||||
|
||||
var media=CGRect(x:0,y:0,width:W,height:H)
|
||||
guard let consumer=CGDataConsumer(url:URL(fileURLWithPath:output) as CFURL),
|
||||
let ctx=CGContext(consumer:consumer,mediaBox:&media,nil) else { fatalError("PDF 생성 실패") }
|
||||
|
||||
// 1. cover
|
||||
begin(ctx,1,"AIO2O · 요청사항 반영 보고서")
|
||||
box(CGRect(x:0,y:0,width:W,height:H),navy,nil,0)
|
||||
pill("IMPLEMENTATION REVIEW",CGRect(x:M,y:112,width:148,height:25),NSColor(calibratedRed:0.42,green:0.78,blue:1,alpha:1))
|
||||
text("AIO2O 테스트 및 요청사항\n반영 결과 보고서",CGRect(x:M,y:166,width:510,height:112),34,.white,.bold,.left,7)
|
||||
text("260727_AIO2O 테스트 및 요청사항.xlsx 기준\n현재 저장소 구현·커밋·검증 캡처 대조",CGRect(x:M,y:310,width:510,height:60),14,NSColor(calibratedWhite:0.78,alpha:1),.regular,.left,7)
|
||||
box(CGRect(x:M,y:435,width:W-2*M,height:176),NSColor.white.withAlphaComponent(0.07),NSColor.white.withAlphaComponent(0.12),16)
|
||||
text("결론",CGRect(x:M+22,y:458,width:460,height:25),13,.white,.bold)
|
||||
text("핵심 업무 흐름은 대부분 구현되었습니다. 견적 목록·상세, 재협상 접수, 목표가 자동계산, 인터넷 최저가, 종료 의견, 결렬폼 통일, VAT 별도 표기, 10원 반올림은 코드 근거가 확인됩니다.\n\n다만 절충안/자동 제안가의 업무 적정성, SG명·유통레벨의 최종 UX, 최저가 VAT 산식은 추가 확인이 필요합니다.",CGRect(x:M+22,y:495,width:W-2*M-44,height:96),11,NSColor(calibratedWhite:0.88,alpha:1),.regular,.left,5)
|
||||
text("작성일 2026.07.31 | 기준 브랜치 feature/negodata | HEAD 775984fe",CGRect(x:M,y:758,width:W-2*M,height:18),8.5,NSColor(calibratedWhite:0.60,alpha:1))
|
||||
end(ctx)
|
||||
|
||||
// 2. summary
|
||||
begin(ctx,2,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("01","종합 요약","엑셀 RAW 시트의 24개 요청을 현재 저장소 상태로 재판정했습니다. 중복 요청은 원 요청 번호를 유지했습니다.")
|
||||
metric("18","완료·반영",M,green); metric("3","부분 반영",M+130,orange); metric("3","확인 필요",M+260,red); metric("24","전체 항목",M+390,blue)
|
||||
text("판정 기준",CGRect(x:M,y:566,width:507,height:24),13,navy,.bold)
|
||||
statusRow("A","완료·반영","사용자 화면과 처리 로직이 모두 확인되거나, 동일 기능을 제공하는 구현 및 검증 캡처가 존재합니다.","18건",green,603,60)
|
||||
statusRow("B","부분 반영","핵심 기능은 있으나 요청한 명칭·선택값·산식 중 일부가 다르거나 배포/운영 확인이 남았습니다.","3건",orange,675,60)
|
||||
statusRow("C","확인 필요","코드는 존재하지만 계산 결과의 업무 적정성을 확정할 수 없거나 요청 산식이 명시적으로 확인되지 않습니다.","3건",red,747,60)
|
||||
end(ctx)
|
||||
|
||||
// 3. system 1
|
||||
begin(ctx,3,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("02","기본 시스템 반영 내역","견적 생성부터 협력사·최저가·협상 화면까지의 공통 요청입니다.")
|
||||
statusRow("01","견적관리 목록·상세/히스토리","견적 목록과 상세 드로어가 있으며, 상세의 채팅 탭·협상카드 탭이 세션 데이터를 연결합니다. 견적번호별 진행/완료 상태와 상세 확인 경로가 마련됐습니다.","완료",green,132)
|
||||
statusRow("02","재협상 접수 및 관리","공급사 포털에서 결렬 건 재협상 요청·철회가 가능하고, 구매자 화면에 재협상 요청 목록·검토 시트·승인/반려 및 알림이 구현됐습니다.","완료",green,226)
|
||||
statusRow("03","MD 제시가 용어·위치·판매가","‘MD’는 ‘구매담당자’로 통일했고 제시가/산정후보를 ‘3. 낙찰기준’으로 이동했습니다. 판매가 설정은 숨김 처리되어 요청 흐름과 일치합니다.","완료",green,320)
|
||||
statusRow("04","목표가 자동 산출","매입가 × (1 − 목표 네고율)로 구매담당자 제시가를 자동 입력합니다. 예: 10,000원, 2% → 9,800원. 프론트 자동계산과 백엔드 가격 처리 근거가 있습니다.","완료",green,414)
|
||||
statusRow("05","공급사/매입가 라벨 일원화","상품 및 견적 화면의 회사별 필드 라벨 설정을 연동해 ‘공급사=매입가’ 표기 정책을 적용할 수 있게 했습니다. 실제 운영 회사 설정값 확인은 필요합니다.","부분",orange,508)
|
||||
statusRow("06","인터넷 최저가 수집","15%에서 멈추던 Worker/큐 처리 문제를 수정하고, 몰별 결과·진행 상태·이력 화면을 재설계했습니다. 다만 요청 산식 ‘(상품가+배송비)/1.1’의 최종 대표값 적용은 코드에서 확정되지 않습니다.","부분",orange,602)
|
||||
statusRow("07","신규 상품 공급사 입력","신규 상품 등록 폼에 공급사 선택기를 추가하고 상품–공급사 매핑을 저장하도록 구현했습니다.","완료",green,696)
|
||||
end(ctx)
|
||||
|
||||
// 4. system 2
|
||||
begin(ctx,4,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("03","협력사·협상 화면 반영","용어 통일, 종료 단계, 가격 표기와 협상 지표를 중심으로 확인했습니다.")
|
||||
statusRow("08","협력사 SG명·유통레벨","취급상품 기반 분류와 공급유형 선택/저장은 구현되어 있습니다. 다만 요청한 SG명 콤보와 유통레벨 4종(제조·총판·대리점·일반유통), 취급상품 삭제가 그대로 완성됐는지는 추가 UX 확인이 필요합니다.","부분",orange,132)
|
||||
statusRow("09","리드타임 → 표준납기","협상 완료 부가정보와 API 설명에 ‘표준납기’가 반영되고 회사 정의 session_fields와 연결됩니다.","완료",green,226)
|
||||
statusRow("10","협상 단가 VAT 별도","협상 상품정보·요약·목록/상세의 단가 표기를 VAT 별도로 통일했습니다. 검증 캡처도 존재합니다.","완료",green,320)
|
||||
statusRow("11","협상 성공률 기준 안내","성공률은 공급사 제시가를 앵커가·목표가와 비교한 1~99 지표입니다. 100% 미달이 결렬 조건은 아니며, 실제 종료는 별도 낙찰/개찰 규칙이 결정합니다.","완료(안내)",blue,414)
|
||||
statusRow("12","협상 종료 추가 의견","타결 부가정보와 결렬 통합폼 모두 ‘기타 의견’을 받으며 sessions.custom.opinion에 저장합니다. 구매자 상세·요약에서 조회되고 완료 후 잠깁니다.","완료",green,508)
|
||||
statusRow("13","결렬 사유·희망가격 통일","기존 RejectRSP/RejectCM을 단일 RejectForm으로 교체했습니다. 결렬사유·희망가·의견을 한 흐름에서 받고 reject_reason/reject_price에 저장합니다.","완료",green,602)
|
||||
statusRow("14","카드 사용 횟수 제한","견적 설정의 card_count(기본 3)를 컨텍스트에서 읽어 실제 사용 가능한 카드 수와 종료 조건을 제한하도록 반영했습니다. 운영 시 기존 세션 회귀검증을 권장합니다.","완료",green,696)
|
||||
end(ctx)
|
||||
|
||||
// 5. case-specific
|
||||
begin(ctx,5,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("04","견적번호별 이슈 반영","EST-202607-05DE·8945·C9D2 사례에서 제기된 가격/종료 흐름을 대조했습니다.")
|
||||
statusRow("15","앵커·자동 제안가 10원 반올림","앵커 생성가, 목표가 후보, 협상카드 카운터를 공통으로 10원 단위 반올림합니다. 예: 15,213원 → 15,210원. 관련 커밋과 단위 테스트가 있습니다.","완료",green,132)
|
||||
statusRow("16","절충안 계산식","협상카드 전술에는 앵커·목표가·직전 제시가를 이용한 중간값 및 목표가 상한 로직이 존재합니다. 다만 ‘절충안’의 기대 공식이 엑셀에 없어 업무적으로 맞는지 확정할 수 없습니다.","확인 필요",red,226)
|
||||
statusRow("17","05DE/C9D2 결렬 희망가격","견적 유형별로 갈리던 결렬 화면을 단일 폼으로 통합해 희망가격 입력 절차를 동일하게 만들었습니다.","완료",green,320)
|
||||
statusRow("18","8945 협상 마무리 개편","종료 후 표준납기·MOQ·발주배수·배송유형 등 회사 정의 부가정보를 선택/입력하고, 기타 의견과 함께 최종 요약에 반영합니다. 배송 선택값은 회사 설정에 따라 구성됩니다.","완료",green,414)
|
||||
statusRow("19","C9D2 자동 제안가 갭","제안가는 카드 전술과 앵커·목표가·직전 제시가의 조합으로 계산되고 10원 반올림됩니다. 17,500원→15,210원의 10.5% 갭이 정책상 적정한지는 목표/앵커 설정을 포함한 별도 검증이 필요합니다.","확인 필요",red,508)
|
||||
statusRow("20","성공/실패 후 의견 조회","협력사가 입력한 종료 의견은 공급사 요약과 구매자 견적 상세 양쪽에서 확인할 수 있고, 종료 후 읽기 전용으로 잠깁니다.","완료",green,602)
|
||||
statusRow("21","중복 요청 통합 반영","엑셀 18/22(성공률), 19/25(종료 의견), 5/23(목표가), 14/17/20(결렬폼)은 각각 하나의 공통 구현으로 해소했습니다.","완료",green,696)
|
||||
end(ctx)
|
||||
|
||||
// 6. evidence
|
||||
begin(ctx,6,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("05","구현 근거","최근 커밋과 현재 코드에서 확인한 핵심 근거입니다. 커밋 단위로 기능 범위를 추적할 수 있습니다.")
|
||||
statusRow("A","b33ae05c · 견적 가격/상품/라벨","목표가 자동입력, 산정후보 위치 이동, 앵커·후보 10원 반올림, 신규 상품 공급사 입력, 회사 설정 라벨 연동.","커밋",purple,132,72)
|
||||
statusRow("B","a56589c6 · 종료폼/의견/VAT","결렬폼 통합, 희망가·사유 저장, 타결/결렬 의견 수취, 상품정보 라벨 연동, 협상 단가 VAT 별도 표기.","커밋",purple,216,72)
|
||||
statusRow("C","30f13483 · 인터넷 최저가","무한 로딩 버그 수정, Worker 설정 보강, 몰별 최저가·진행/상세 UI 및 이력 저장 개선.","커밋",purple,300,72)
|
||||
statusRow("D","9dca78dc · 완료 부가정보","완료 부가정보 수취·요약 표시·잠금, 구매자 상세 노출, VAT 표기 통일.","커밋",purple,384,72)
|
||||
statusRow("E","2a004734 · 견적 상세 연결","견적 상세의 채팅·협상카드 탭 연동과 드로어 탐색 개선.","커밋",purple,468,72)
|
||||
statusRow("F","775984fe / f554202c · 반올림","협상카드 카운터와 자동 앵커를 10원 단위 반올림으로 통일.","커밋",purple,552,72)
|
||||
statusRow("G","화면 검증 캡처","목록/상세, 종료 의견, VAT, 완료 요약, 읽기 전용 잠금 등 12개 캡처가 저장소 루트에 남아 있습니다.","캡처",blue,636,72)
|
||||
text("주의: 본 보고서는 2026-07-31 현재 로컬 저장소의 코드·커밋·캡처를 기준으로 합니다. 운영 배포 여부와 기존 데이터 마이그레이션 상태는 별도 확인 대상입니다.",CGRect(x:M,y:742,width:W-2*M,height:42),8.8,muted,.regular,.left,3)
|
||||
end(ctx)
|
||||
|
||||
// 7. actions
|
||||
begin(ctx,7,"AIO2O · 요청사항 반영 보고서")
|
||||
heading("06","남은 확인 및 권고","기능 누락이라기보다 업무 규칙·운영 설정을 확정해야 하는 항목입니다.")
|
||||
statusRow("1","최저가 VAT 대표값 확정","현재 LPS는 상품가와 배송비를 별도 수집·표시합니다. 대표 최저가를 반드시 (상품가+배송비)/1.1로 저장할지, 화면 표시만 할지 정책을 확정한 뒤 테스트를 추가해야 합니다.","우선순위 높음",red,142,98)
|
||||
statusRow("2","절충안/자동 제안가 기준 검증","05DE·C9D2의 실제 앵커가·목표가·직전 제시가를 넣어 계산 결과를 재현하고, 허용 최대 인하폭 또는 목표가 클램프 기준을 업무 담당자와 합의하는 것이 좋습니다.","우선순위 높음",red,254,98)
|
||||
statusRow("3","SG명·유통레벨 UX 확정","현행 취급상품 기반 분류/공급유형을 요청한 SG 콤보와 유통레벨 4종으로 대체할지, 데이터 모델을 유지한 채 라벨만 조정할지 결정이 필요합니다.","우선순위 중간",orange,366,98)
|
||||
statusRow("4","운영 배포·기존 세션 회귀검증","종료폼, 의견, 카드 횟수 제한은 신규 코드에 반영됐습니다. 운영 컨테이너 재빌드 후 기존 세션과 신규 세션에서 각각 1회 이상 확인해야 합니다.","배포 확인",blue,478,98)
|
||||
box(CGRect(x:M,y:612,width:W-2*M,height:118),purple.withAlphaComponent(0.08),purple.withAlphaComponent(0.28),12)
|
||||
text("권장 최종 승인 기준",CGRect(x:M+18,y:630,width:470,height:22),12,purple,.bold)
|
||||
text("① 운영 배포 버전 확인 ② 대표 견적 3건 시나리오 재실행 ③ 계산식 2건 서면 확정\n④ SG/유통레벨 화면 승인 ⑤ 완료·결렬 의견이 구매자 상세에 저장되는지 확인",CGRect(x:M+18,y:662,width:470,height:50),10,ink,.medium,.left,5)
|
||||
text("— End of report —",CGRect(x:M,y:760,width:W-2*M,height:20),8,muted,.medium,.center)
|
||||
end(ctx)
|
||||
|
||||
ctx.closePDF()
|
||||
BIN
docs/backend-advanced-concepts-ko.pdf
Normal file
BIN
docs/backend-advanced-concepts-ko.pdf
Normal file
Binary file not shown.
790
docs/backend_advanced_guide.swift
Normal file
790
docs/backend_advanced_guide.swift
Normal file
@ -0,0 +1,790 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
let outPath = CommandLine.arguments.count > 1
|
||||
? CommandLine.arguments[1]
|
||||
: "docs/backend-advanced-concepts-ko.pdf"
|
||||
|
||||
let W: CGFloat = 595
|
||||
let H: CGFloat = 842
|
||||
let margin: CGFloat = 44
|
||||
|
||||
let navy = NSColor(calibratedRed: 0.055, green: 0.086, blue: 0.16, alpha: 1)
|
||||
let ink = NSColor(calibratedRed: 0.10, green: 0.13, blue: 0.18, alpha: 1)
|
||||
let muted = NSColor(calibratedRed: 0.37, green: 0.42, blue: 0.50, alpha: 1)
|
||||
let paper = NSColor(calibratedRed: 0.975, green: 0.98, blue: 0.99, alpha: 1)
|
||||
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
|
||||
let blue = NSColor(calibratedRed: 0.16, green: 0.39, blue: 0.93, alpha: 1)
|
||||
let cyan = NSColor(calibratedRed: 0.10, green: 0.69, blue: 0.74, alpha: 1)
|
||||
let green = NSColor(calibratedRed: 0.10, green: 0.63, blue: 0.39, alpha: 1)
|
||||
let orange = NSColor(calibratedRed: 0.94, green: 0.47, blue: 0.12, alpha: 1)
|
||||
let red = NSColor(calibratedRed: 0.88, green: 0.25, blue: 0.28, alpha: 1)
|
||||
let purple = NSColor(calibratedRed: 0.48, green: 0.32, blue: 0.89, alpha: 1)
|
||||
|
||||
func pdfRect(_ r: CGRect) -> CGRect {
|
||||
CGRect(x: r.minX, y: H - r.maxY, width: r.width, height: r.height)
|
||||
}
|
||||
|
||||
func pdfPoint(_ p: CGPoint) -> CGPoint {
|
||||
CGPoint(x: p.x, y: H - p.y)
|
||||
}
|
||||
|
||||
func font(_ size: CGFloat, _ weight: NSFont.Weight = .regular) -> NSFont {
|
||||
NSFont(name: "Apple SD Gothic Neo", size: size)
|
||||
?? NSFont.systemFont(ofSize: size, weight: weight)
|
||||
}
|
||||
|
||||
func mono(_ size: CGFloat) -> NSFont {
|
||||
NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
|
||||
func attrs(_ size: CGFloat, color: NSColor = ink, weight: NSFont.Weight = .regular,
|
||||
align: NSTextAlignment = .left, lineSpacing: CGFloat = 3) -> [NSAttributedString.Key: Any] {
|
||||
let p = NSMutableParagraphStyle()
|
||||
p.alignment = align
|
||||
p.lineSpacing = lineSpacing
|
||||
p.lineBreakMode = .byWordWrapping
|
||||
return [.font: font(size, weight), .foregroundColor: color, .paragraphStyle: p]
|
||||
}
|
||||
|
||||
func drawText(_ text: String, _ rect: CGRect, size: CGFloat = 11, color: NSColor = ink,
|
||||
weight: NSFont.Weight = .regular, align: NSTextAlignment = .left,
|
||||
lineSpacing: CGFloat = 3) {
|
||||
NSAttributedString(string: text, attributes: attrs(size, color: color, weight: weight,
|
||||
align: align, lineSpacing: lineSpacing))
|
||||
.draw(with: pdfRect(rect), options: [.usesLineFragmentOrigin, .usesFontLeading])
|
||||
}
|
||||
|
||||
func rounded(_ rect: CGRect, radius: CGFloat = 12, fill: NSColor = .white,
|
||||
stroke: NSColor? = line, width: CGFloat = 1) {
|
||||
let p = NSBezierPath(roundedRect: pdfRect(rect), xRadius: radius, yRadius: radius)
|
||||
fill.setFill(); p.fill()
|
||||
if let stroke { stroke.setStroke(); p.lineWidth = width; p.stroke() }
|
||||
}
|
||||
|
||||
func pill(_ text: String, x: CGFloat, y: CGFloat, w: CGFloat, color: NSColor) {
|
||||
rounded(CGRect(x: x, y: y, width: w, height: 25), radius: 12.5,
|
||||
fill: color.withAlphaComponent(0.12), stroke: nil)
|
||||
drawText(text, CGRect(x: x, y: y + 5, width: w, height: 16), size: 9.5,
|
||||
color: color, weight: .semibold, align: .center)
|
||||
}
|
||||
|
||||
func arrow(_ from: CGPoint, _ to: CGPoint, color: NSColor = muted) {
|
||||
let from = pdfPoint(from), to = pdfPoint(to)
|
||||
let p = NSBezierPath(); p.move(to: from); p.line(to: to)
|
||||
color.setStroke(); p.lineWidth = 1.8; p.stroke()
|
||||
let a = atan2(to.y - from.y, to.x - from.x)
|
||||
let l: CGFloat = 7
|
||||
let h = NSBezierPath()
|
||||
h.move(to: to)
|
||||
h.line(to: CGPoint(x: to.x - l * cos(a - .pi / 6), y: to.y - l * sin(a - .pi / 6)))
|
||||
h.line(to: CGPoint(x: to.x - l * cos(a + .pi / 6), y: to.y - l * sin(a + .pi / 6)))
|
||||
h.close(); color.setFill(); h.fill()
|
||||
}
|
||||
|
||||
func node(_ title: String, _ sub: String, rect: CGRect, color: NSColor) {
|
||||
rounded(rect, radius: 10, fill: color.withAlphaComponent(0.10),
|
||||
stroke: color.withAlphaComponent(0.55), width: 1.2)
|
||||
drawText(title, CGRect(x: rect.minX + 8, y: rect.minY + 10, width: rect.width - 16, height: 18),
|
||||
size: 10.5, color: color, weight: .bold, align: .center)
|
||||
drawText(sub, CGRect(x: rect.minX + 8, y: rect.minY + 31, width: rect.width - 16, height: rect.height - 36),
|
||||
size: 8.5, color: muted, align: .center, lineSpacing: 1)
|
||||
}
|
||||
|
||||
func sectionTitle(_ number: String, _ title: String, _ subtitle: String, color: NSColor) {
|
||||
pill(number, x: margin, y: 48, w: 34, color: color)
|
||||
drawText(title, CGRect(x: 86, y: 46, width: 450, height: 30), size: 22,
|
||||
color: navy, weight: .bold)
|
||||
drawText(subtitle, CGRect(x: margin, y: 82, width: W - 2 * margin, height: 26),
|
||||
size: 10.5, color: muted)
|
||||
}
|
||||
|
||||
func footer(_ page: Int, _ label: String = "O2O Negosium · Backend Concepts") {
|
||||
let p = NSBezierPath()
|
||||
p.move(to: CGPoint(x: margin, y: H - 34)); p.line(to: CGPoint(x: W - margin, y: H - 34))
|
||||
line.setStroke(); p.lineWidth = 0.7; p.stroke()
|
||||
drawText(label, CGRect(x: margin, y: H - 28, width: 350, height: 14), size: 7.5, color: muted)
|
||||
drawText("\(page)", CGRect(x: W - margin - 35, y: H - 28, width: 35, height: 14),
|
||||
size: 8, color: muted, align: .right)
|
||||
}
|
||||
|
||||
func callout(_ title: String, _ body: String, rect: CGRect, color: NSColor) {
|
||||
rounded(rect, radius: 12, fill: color.withAlphaComponent(0.08),
|
||||
stroke: color.withAlphaComponent(0.35))
|
||||
rounded(CGRect(x: rect.minX, y: rect.minY, width: 5, height: rect.height),
|
||||
radius: 2.5, fill: color, stroke: nil)
|
||||
drawText(title, CGRect(x: rect.minX + 16, y: rect.minY + 12,
|
||||
width: rect.width - 28, height: 20),
|
||||
size: 11, color: color, weight: .bold)
|
||||
drawText(body, CGRect(x: rect.minX + 16, y: rect.minY + 37,
|
||||
width: rect.width - 28, height: rect.height - 45),
|
||||
size: 9.5, color: ink, lineSpacing: 3)
|
||||
}
|
||||
|
||||
func comparison(_ leftTitle: String, _ left: String, _ rightTitle: String, _ right: String,
|
||||
y: CGFloat, color: NSColor) {
|
||||
let gap: CGFloat = 14
|
||||
let cw = (W - 2 * margin - gap) / 2
|
||||
callout(leftTitle, left, rect: CGRect(x: margin, y: y, width: cw, height: 126), color: red)
|
||||
callout(rightTitle, right, rect: CGRect(x: margin + cw + gap, y: y, width: cw, height: 126), color: color)
|
||||
}
|
||||
|
||||
func codeBox(_ title: String, _ path: String, _ code: String, rect: CGRect, accent: NSColor) {
|
||||
rounded(rect, radius: 10, fill: navy, stroke: nil)
|
||||
drawText(title, CGRect(x: rect.minX + 14, y: rect.minY + 11,
|
||||
width: rect.width - 28, height: 17),
|
||||
size: 10, color: .white, weight: .bold)
|
||||
drawText(path, CGRect(x: rect.minX + 14, y: rect.minY + 30,
|
||||
width: rect.width - 28, height: 14),
|
||||
size: 7.5, color: accent)
|
||||
let p = NSMutableParagraphStyle(); p.lineSpacing = 2; p.lineBreakMode = .byClipping
|
||||
NSAttributedString(string: code, attributes: [.font: mono(7.8), .foregroundColor: NSColor(calibratedWhite: 0.88, alpha: 1), .paragraphStyle: p])
|
||||
.draw(with: pdfRect(CGRect(x: rect.minX + 14, y: rect.minY + 51,
|
||||
width: rect.width - 28, height: rect.height - 60)),
|
||||
options: [.usesLineFragmentOrigin])
|
||||
}
|
||||
|
||||
func beginPage(_ ctx: CGContext, page: Int, label: String = "O2O Negosium · Backend Concepts") {
|
||||
ctx.beginPDFPage(nil)
|
||||
ctx.saveGState()
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false)
|
||||
paper.setFill(); NSBezierPath(rect: pdfRect(CGRect(x: 0, y: 0, width: W, height: H))).fill()
|
||||
footer(page, label)
|
||||
}
|
||||
|
||||
func endPage(_ ctx: CGContext) {
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
ctx.restoreGState()
|
||||
ctx.endPDFPage()
|
||||
}
|
||||
|
||||
var mediaBox = CGRect(x: 0, y: 0, width: W, height: H)
|
||||
guard let consumer = CGDataConsumer(url: URL(fileURLWithPath: outPath) as CFURL),
|
||||
let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
|
||||
fatalError("PDF context 생성 실패")
|
||||
}
|
||||
|
||||
// 1 — Cover
|
||||
beginPage(ctx, page: 1, label: "O2O Negosium · Backend Field Guide")
|
||||
rounded(CGRect(x: 0, y: 0, width: W, height: H), radius: 0, fill: navy, stroke: nil)
|
||||
for i in 0..<7 {
|
||||
let x = CGFloat(50 + i * 78)
|
||||
let c = [blue, cyan, green, orange, purple][i % 5]
|
||||
rounded(CGRect(x: x, y: 85 + CGFloat((i % 3) * 28), width: 48, height: 48),
|
||||
radius: 24, fill: c.withAlphaComponent(0.35), stroke: nil)
|
||||
}
|
||||
drawText("BACKEND", CGRect(x: margin, y: 190, width: 507, height: 35), size: 15,
|
||||
color: cyan, weight: .bold)
|
||||
drawText("어려운 개념 5가지,\n코드로 이해하기", CGRect(x: margin, y: 228, width: 507, height: 118),
|
||||
size: 36, color: .white, weight: .bold, lineSpacing: 7)
|
||||
drawText("분산 시스템 · 트랜잭션/동시성 · 멀티테넌시\n캐시 정합성 · 스케줄러/배치",
|
||||
CGRect(x: margin, y: 370, width: 507, height: 62), size: 15,
|
||||
color: NSColor(calibratedWhite: 0.80, alpha: 1), lineSpacing: 8)
|
||||
rounded(CGRect(x: margin, y: 485, width: 507, height: 154), radius: 18,
|
||||
fill: NSColor.white.withAlphaComponent(0.07),
|
||||
stroke: NSColor.white.withAlphaComponent(0.15))
|
||||
drawText("이 문서는 이렇게 읽어요", CGRect(x: 66, y: 510, width: 455, height: 25),
|
||||
size: 14, color: .white, weight: .bold)
|
||||
drawText("① 일상 비유로 개념 잡기\n② 실제 서비스 흐름을 그림으로 보기\n③ 프로젝트 코드에서 구현 확인하기\n④ 없을 때 생기는 문제와 비교하기",
|
||||
CGRect(x: 66, y: 548, width: 455, height: 78), size: 11.5,
|
||||
color: NSColor(calibratedWhite: 0.88, alpha: 1), lineSpacing: 6)
|
||||
drawText("Generated from the current repository · 2026-07-29",
|
||||
CGRect(x: margin, y: 758, width: 507, height: 18), size: 8.5,
|
||||
color: NSColor(calibratedWhite: 0.62, alpha: 1))
|
||||
endPage(ctx)
|
||||
|
||||
// 2 — Architecture map
|
||||
beginPage(ctx, page: 2)
|
||||
drawText("먼저, 서비스 지도를 봅시다", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||||
size: 24, color: navy, weight: .bold)
|
||||
drawText("다섯 개념은 따로 노는 것이 아니라, 한 요청이 여러 서비스와 저장소를 지나면서 함께 작동합니다.",
|
||||
CGRect(x: margin, y: 88, width: 507, height: 32), size: 10.5, color: muted)
|
||||
node("사용자", "브라우저", rect: CGRect(x: 44, y: 170, width: 90, height: 62), color: purple)
|
||||
node("Backend", "채팅·공급사", rect: CGRect(x: 184, y: 145, width: 102, height: 72), color: blue)
|
||||
node("Agent", "AI 협상", rect: CGRect(x: 348, y: 145, width: 102, height: 72), color: orange)
|
||||
node("Negodata", "견적·관리", rect: CGRect(x: 184, y: 270, width: 102, height: 72), color: cyan)
|
||||
node("LPS", "최저가 Worker", rect: CGRect(x: 348, y: 270, width: 102, height: 72), color: green)
|
||||
node("PostgreSQL", "업무 원본", rect: CGRect(x: 184, y: 405, width: 130, height: 72), color: purple)
|
||||
node("Redis", "앵커링 캐시", rect: CGRect(x: 368, y: 405, width: 100, height: 72), color: red)
|
||||
arrow(CGPoint(x: 134, y: 200), CGPoint(x: 184, y: 182), color: purple)
|
||||
arrow(CGPoint(x: 286, y: 180), CGPoint(x: 348, y: 180), color: blue)
|
||||
arrow(CGPoint(x: 235, y: 217), CGPoint(x: 235, y: 270), color: cyan)
|
||||
arrow(CGPoint(x: 286, y: 304), CGPoint(x: 348, y: 304), color: green)
|
||||
arrow(CGPoint(x: 235, y: 342), CGPoint(x: 235, y: 405), color: purple)
|
||||
arrow(CGPoint(x: 399, y: 342), CGPoint(x: 415, y: 405), color: red)
|
||||
callout("① 경계가 생기면 ‘분산 시스템’", "서비스 A가 서비스 B를 네트워크로 호출하는 순간, 지연·타임아웃·부분 실패를 다뤄야 합니다.",
|
||||
rect: CGRect(x: margin, y: 525, width: 246, height: 105), color: blue)
|
||||
callout("② 여러 실행자가 만나면 ‘동시성’", "사용자 클릭과 스케줄러가 같은 견적을 동시에 마감할 수 있어, DB가 최종 심판 역할을 합니다.",
|
||||
rect: CGRect(x: 305, y: 525, width: 246, height: 105), color: orange)
|
||||
callout("③ 빠르게 읽되 원본을 지키면 ‘캐시’", "Redis와 프로세스 메모리는 복사본입니다. PostgreSQL과 설정 파일이 원본입니다.",
|
||||
rect: CGRect(x: margin, y: 650, width: 246, height: 105), color: red)
|
||||
callout("④ 회사별 경계를 지키면 ‘멀티테넌시’", "요청 헤더에서 회사 ID를 결정하고, 회사별 설정·엔진을 선택합니다.",
|
||||
rect: CGRect(x: 305, y: 650, width: 246, height: 105), color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 3 — Distributed systems: concept first
|
||||
beginPage(ctx, page: 3)
|
||||
sectionTitle("3", "분산 시스템 — 개념부터", "여러 독립 실행 단위가 네트워크를 통해 하나의 업무를 완성하는 시스템", color: blue)
|
||||
callout("정확한 정의", "프로세스·컨테이너·서버가 각자 메모리와 실행 상태를 가지고, HTTP나 메시지로 통신하는 구조입니다. 한 서비스의 함수 호출과 달리 상대의 상태를 직접 볼 수 없고, 네트워크 응답만으로 결과를 추론해야 합니다.",
|
||||
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: blue)
|
||||
drawText("왜 어려운가: 네트워크에는 네 가지 결과가 있습니다", CGRect(x: margin, y: 244, width: 507, height: 24),
|
||||
size: 13.5, color: navy, weight: .bold)
|
||||
let distCases: [(String, String, NSColor)] = [
|
||||
("성공", "상대가 처리했고 응답도 받음", green),
|
||||
("명확한 실패", "상대가 오류 응답을 보냄", red),
|
||||
("연결 실패", "상대에게 요청이 도착하지 않음", orange),
|
||||
("애매한 타임아웃", "처리는 됐지만 응답만 늦었을 수도 있음", purple),
|
||||
]
|
||||
for (i, c) in distCases.enumerated() {
|
||||
let col = i % 2, row = i / 2
|
||||
let x = margin + CGFloat(col) * 260
|
||||
let y = CGFloat(286 + row * 86)
|
||||
callout(c.0, c.1, rect: CGRect(x: x, y: y, width: 247, height: 70), color: c.2)
|
||||
}
|
||||
drawText("대표적인 대응 수단", CGRect(x: margin, y: 475, width: 507, height: 24),
|
||||
size: 13.5, color: navy, weight: .bold)
|
||||
callout("Timeout", "얼마나 기다릴지 상한을 둡니다. 짧으면 정상 요청도 실패하고, 길면 자원이 오래 묶입니다.",
|
||||
rect: CGRect(x: margin, y: 515, width: 159, height: 92), color: blue)
|
||||
callout("Retry", "일시 실패를 다시 시도합니다. 단, 중복 처리에 안전한 작업에서만 제한적으로 사용합니다.",
|
||||
rect: CGRect(x: 218, y: 515, width: 159, height: 92), color: orange)
|
||||
callout("Idempotency", "같은 요청을 여러 번 보내도 결과가 한 번 처리한 것과 같도록 만듭니다.",
|
||||
rect: CGRect(x: 392, y: 515, width: 159, height: 92), color: purple)
|
||||
callout("Fallback", "주 서비스가 실패하면 대체 경로·기본값·이전 데이터를 사용합니다. 대체 결과가 업무적으로 허용될 때만 가능합니다.",
|
||||
rect: CGRect(x: margin, y: 630, width: 247, height: 92), color: green)
|
||||
callout("Circuit breaker", "실패가 계속되는 서비스를 잠시 호출하지 않아 연쇄 장애를 막습니다. 현재 프로젝트에는 명시적 구현이 없습니다.",
|
||||
rect: CGRect(x: 304, y: 630, width: 247, height: 92), color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 4 distributed concept
|
||||
beginPage(ctx, page: 4)
|
||||
sectionTitle("3", "분산 시스템과 장애 대응", "한 프로그램이 아니라 여러 서비스가 네트워크로 협력하는 구조", color: blue)
|
||||
callout("쉬운 비유", "한 식당 안에서 주방과 홀 직원이 말로 협업하는 것이 단일 시스템이라면, 분산 시스템은 서로 다른 건물의 팀이 전화로 협업하는 것입니다. 전화는 늦거나 끊길 수 있고, 상대가 일을 끝냈는데 답만 못 받을 수도 있습니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 105), color: blue)
|
||||
drawText("프로젝트의 대표 흐름", CGRect(x: margin, y: 266, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("Backend", "사용자 채팅 요청", rect: CGRect(x: 52, y: 315, width: 110, height: 74), color: blue)
|
||||
node("HTTPX", "timeout 설정", rect: CGRect(x: 242, y: 315, width: 110, height: 74), color: cyan)
|
||||
node("Agent", "협상 턴 계산", rect: CGRect(x: 432, y: 315, width: 110, height: 74), color: orange)
|
||||
arrow(CGPoint(x: 162, y: 352), CGPoint(x: 242, y: 352), color: blue)
|
||||
arrow(CGPoint(x: 352, y: 352), CGPoint(x: 432, y: 352), color: cyan)
|
||||
drawText("성공", CGRect(x: 394, y: 414, width: 80, height: 18), size: 9, color: green, weight: .bold)
|
||||
arrow(CGPoint(x: 485, y: 389), CGPoint(x: 485, y: 458), color: green)
|
||||
node("응답 반영", "채팅 상태 저장", rect: CGRect(x: 430, y: 458, width: 112, height: 65), color: green)
|
||||
drawText("타임아웃/실패", CGRect(x: 185, y: 414, width: 105, height: 18), size: 9, color: red, weight: .bold)
|
||||
arrow(CGPoint(x: 297, y: 389), CGPoint(x: 297, y: 458), color: red)
|
||||
node("안전한 실패", "ok=false 반환", rect: CGRect(x: 241, y: 458, width: 112, height: 65), color: red)
|
||||
callout("중요한 함정: 타임아웃 ≠ 상대가 아무 일도 안 함", "Agent가 DB 상태를 이미 전진시킨 직후 응답만 늦었을 수 있습니다. 그래서 무조건 재시도하면 같은 턴을 두 번 처리할 위험이 있습니다. 코드가 timed_out을 따로 표시하는 이유입니다.",
|
||||
rect: CGRect(x: margin, y: 566, width: 507, height: 105), color: orange)
|
||||
comparison("이 장치가 없으면", "Agent가 느린 순간 Backend 요청도 끝없이 대기합니다. 무작정 재시도하면 협상 step이 두 번 전진할 수 있습니다.",
|
||||
"현재 방식", "HTTP timeout을 두고 성공/일반 실패/타임아웃을 구분합니다. 호출 경계에서 예외를 응답 객체로 변환합니다.",
|
||||
y: 695, color: blue)
|
||||
endPage(ctx)
|
||||
|
||||
// 5 distributed code
|
||||
beginPage(ctx, page: 5)
|
||||
sectionTitle("3", "분산 시스템 — 실제 코드", "서비스 경계마다 timeout, fallback, best-effort 정책이 다릅니다.", color: blue)
|
||||
codeBox("Agent 호출: 타임아웃을 별도 상태로 반환",
|
||||
"backend/services/agent_client.py · lines 81–91",
|
||||
"""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=agent_config.base_url,
|
||||
timeout=agent_config.timeout_sec,
|
||||
) as cli:
|
||||
resp = await cli.post("/v1/chat", json=body, headers=headers)
|
||||
|
||||
except httpx.TimeoutException as ex:
|
||||
# Agent가 이미 처리했을 수 있어 단순 재시도는 위험
|
||||
return AgentTurn(ok=False, timed_out=True)
|
||||
except Exception:
|
||||
return AgentTurn(ok=False)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 245), accent: cyan)
|
||||
codeBox("카탈로그 변경 알림: 핵심 업무를 막지 않는 best-effort",
|
||||
"negodata/backend/services/agent_notify.py · lines 15–26",
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as cli:
|
||||
await cli.post(f"{base}/v1/catalog-refresh-all")
|
||||
except Exception as ex:
|
||||
# 알림 실패는 카드 작업을 막지 않는다
|
||||
LOG.w(f"[agent_notify] 변경 알림 실패(무시): {ex}")
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 395, width: 507, height: 160), accent: green)
|
||||
callout("어떻게 정책을 고르나요?", "결제처럼 반드시 성공해야 하는 작업은 실패를 호출자에게 알려 재처리해야 합니다. 반면 ‘캐시 무효화 알림’처럼 보조적인 작업은 실패해도 핵심 카드 변경을 성공시킬 수 있습니다. 모든 외부 호출을 똑같이 재시도하면 안 됩니다.",
|
||||
rect: CGRect(x: margin, y: 580, width: 507, height: 105), color: blue)
|
||||
drawText("기억할 단어", CGRect(x: margin, y: 712, width: 120, height: 20), size: 12,
|
||||
color: navy, weight: .bold)
|
||||
pill("timeout", x: 150, y: 707, w: 82, color: blue)
|
||||
pill("partial failure", x: 242, y: 707, w: 102, color: orange)
|
||||
pill("best-effort", x: 354, y: 707, w: 92, color: green)
|
||||
pill("idempotency", x: 456, y: 707, w: 90, color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 6 — Transactions and concurrency: concept first
|
||||
beginPage(ctx, page: 6)
|
||||
sectionTitle("4", "트랜잭션·동시성 — 개념부터", "데이터의 일관성을 지키는 작업 단위와, 동시에 실행되는 요청을 제어하는 방법", color: orange)
|
||||
drawText("트랜잭션의 ACID", CGRect(x: margin, y: 126, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
let acid: [(String, String, NSColor)] = [
|
||||
("A · Atomicity", "전부 성공하거나 전부 취소", orange),
|
||||
("C · Consistency", "규칙을 만족하는 상태로 이동", green),
|
||||
("I · Isolation", "동시 작업의 중간 상태를 서로 숨김", purple),
|
||||
("D · Durability", "COMMIT된 결과는 장애 후에도 보존", blue),
|
||||
]
|
||||
for (i, a) in acid.enumerated() {
|
||||
let x = margin + CGFloat(i % 2) * 260
|
||||
let y = CGFloat(165 + (i / 2) * 84)
|
||||
callout(a.0, a.1, rect: CGRect(x: x, y: y, width: 247, height: 68), color: a.2)
|
||||
}
|
||||
drawText("동시성 문제는 어떻게 생기나?", CGRect(x: margin, y: 352, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("요청 A", "status=OPEN 읽음", rect: CGRect(x: 48, y: 401, width: 116, height: 64), color: blue)
|
||||
node("요청 B", "status=OPEN 읽음", rect: CGRect(x: 48, y: 500, width: 116, height: 64), color: purple)
|
||||
node("둘 다 처리", "중복 마감·중복 메일", rect: CGRect(x: 250, y: 449, width: 130, height: 70), color: red)
|
||||
arrow(CGPoint(x: 164, y: 433), CGPoint(x: 250, y: 471), color: blue)
|
||||
arrow(CGPoint(x: 164, y: 532), CGPoint(x: 250, y: 497), color: purple)
|
||||
callout("해결 ① 비관적 잠금", "SELECT ... FOR UPDATE로 먼저 행을 잠급니다. 명확하지만 잠금 대기와 deadlock을 관리해야 합니다.",
|
||||
rect: CGRect(x: 408, y: 391, width: 143, height: 92), color: orange)
|
||||
callout("해결 ② 조건부 갱신", "UPDATE ... WHERE status=OPEN 후 rowcount를 확인합니다. 상태 검사와 변경이 원자적으로 일어납니다.",
|
||||
rect: CGRect(x: 408, y: 497, width: 143, height: 92), color: green)
|
||||
callout("격리 수준과 잠금은 만능이 아님", "격리를 높이면 안전성은 커지지만 동시 처리량이 줄고 대기·교착 가능성이 커집니다. 업무 규칙에 맞는 최소 범위의 트랜잭션과 조건부 상태 전이가 실용적입니다.",
|
||||
rect: CGRect(x: margin, y: 624, width: 507, height: 92), color: orange)
|
||||
callout("COMMIT / ROLLBACK", "COMMIT은 변경 확정, ROLLBACK은 현재 트랜잭션의 미확정 변경 취소입니다. 외부 이메일 발송은 DB rollback으로 되돌릴 수 없다는 점도 중요합니다.",
|
||||
rect: CGRect(x: margin, y: 720, width: 507, height: 72), color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 7 transaction concept
|
||||
beginPage(ctx, page: 7)
|
||||
sectionTitle("4", "DB 트랜잭션과 동시성 제어", "여러 작업을 하나로 묶고, 동시에 온 요청 중 한 명만 통과시키는 기술", color: orange)
|
||||
callout("트랜잭션이란?", "은행 이체에서 ‘내 계좌 차감’과 ‘상대 계좌 증가’가 둘 다 성공하거나 둘 다 취소되어야 하듯, 관련 DB 변경을 하나의 작업 단위로 묶는 것입니다. 중간에 실패하면 rollback합니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: orange)
|
||||
drawText("동시 마감 문제", CGRect(x: margin, y: 252, width: 200, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("사용자 클릭", "마감 요청 A", rect: CGRect(x: 50, y: 300, width: 110, height: 66), color: blue)
|
||||
node("스케줄러", "마감 요청 B", rect: CGRect(x: 50, y: 405, width: 110, height: 66), color: purple)
|
||||
node("조건부 UPDATE", "status != CLOSED", rect: CGRect(x: 243, y: 350, width: 120, height: 74), color: orange)
|
||||
node("PostgreSQL", "원자적으로 판정", rect: CGRect(x: 430, y: 350, width: 112, height: 74), color: green)
|
||||
arrow(CGPoint(x: 160, y: 333), CGPoint(x: 243, y: 376), color: blue)
|
||||
arrow(CGPoint(x: 160, y: 438), CGPoint(x: 243, y: 399), color: purple)
|
||||
arrow(CGPoint(x: 363, y: 387), CGPoint(x: 430, y: 387), color: orange)
|
||||
callout("승자", "영향받은 행 수(rowcount) = 1\n마감 판정 권한 획득",
|
||||
rect: CGRect(x: 76, y: 518, width: 205, height: 88), color: green)
|
||||
callout("패자/재요청", "rowcount = 0\n이미 닫혔으므로 추가 처리 중단",
|
||||
rect: CGRect(x: 314, y: 518, width: 205, height: 88), color: red)
|
||||
comparison("단순 SELECT 후 UPDATE", "두 요청이 동시에 OPEN을 읽으면 둘 다 마감·낙찰 로직을 실행할 수 있습니다. 이메일도 두 번 발송될 수 있습니다.",
|
||||
"조건부 UPDATE", "DB가 상태 검사와 변경을 한 문장으로 처리합니다. 먼저 성공한 요청만 rowcount=1을 받습니다.",
|
||||
y: 640, color: orange)
|
||||
endPage(ctx)
|
||||
|
||||
// 8 transaction code
|
||||
beginPage(ctx, page: 8)
|
||||
sectionTitle("4", "트랜잭션·동시성 — 실제 코드", "애플리케이션의 if문보다 DB의 원자적 UPDATE가 강한 최종 방어선입니다.", color: orange)
|
||||
codeBox("조건부 상태 전이: 마감 권한 선점",
|
||||
"negodata/backend/crud/quotation_crud.py · lines 482–500",
|
||||
"""
|
||||
query = (
|
||||
update(quotations)
|
||||
.where(
|
||||
quotations.qt_id == qt_id,
|
||||
quotations.status != QuotationStatus.CLOSED.value,
|
||||
quotations.deleted == False,
|
||||
)
|
||||
.values(
|
||||
status=QuotationStatus.CLOSED.value,
|
||||
updated_at=GTime.UTC(),
|
||||
)
|
||||
)
|
||||
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 235), accent: orange)
|
||||
codeBox("트랜잭션 실패 시 rollback",
|
||||
"negodata/backend/common/database/db_session_manager.py · lines 116–127",
|
||||
"""
|
||||
try:
|
||||
await db.commit()
|
||||
return ErrorType.SUCCESS
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
return ErrorType.DB_ALREADY_SAME_KEY
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 390, width: 507, height: 175), accent: red)
|
||||
callout("왜 rowcount를 보나요?", "UPDATE가 에러 없이 실행됐다는 사실만으로는 내가 상태를 바꿨는지 알 수 없습니다. WHERE 조건에 맞는 행이 없으면 SQL은 정상 실행되지만 변경 행은 0개입니다. 그래서 1이면 승자, 0이면 이미 다른 요청이 처리한 것으로 판단합니다.",
|
||||
rect: CGRect(x: margin, y: 592, width: 507, height: 108), color: orange)
|
||||
callout("실무 체크", "트랜잭션은 짧게 유지하고, 외부 HTTP·이메일처럼 오래 걸리는 작업을 DB 트랜잭션 안에 오래 붙잡아 두지 않습니다.",
|
||||
rect: CGRect(x: margin, y: 720, width: 507, height: 65), color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 9 — Multitenancy: concept first
|
||||
beginPage(ctx, page: 9)
|
||||
sectionTitle("5", "멀티테넌시 — 개념부터", "하나의 애플리케이션을 여러 고객사가 공유하면서 논리적으로 격리하는 설계", color: purple)
|
||||
callout("Tenant란?", "서비스를 사용하는 독립 고객 단위입니다. 이 프로젝트에서는 주로 ‘회사’가 tenant입니다. 같은 API와 서버를 쓰더라도 회사별 데이터, 설정, 권한, 협상 정책이 섞이면 안 됩니다.",
|
||||
rect: CGRect(x: margin, y: 126, width: 507, height: 88), color: purple)
|
||||
drawText("대표적인 데이터 격리 모델", CGRect(x: margin, y: 242, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
callout("DB 분리", "회사마다 별도 DB\n격리 강함 · 운영비 높음",
|
||||
rect: CGRect(x: margin, y: 282, width: 159, height: 88), color: blue)
|
||||
callout("Schema 분리", "한 DB 안에서 schema 분리\n중간 수준의 격리와 비용",
|
||||
rect: CGRect(x: 218, y: 282, width: 159, height: 88), color: cyan)
|
||||
callout("Row 공유", "같은 테이블 + tenant_id\n효율적 · 쿼리 누락 위험",
|
||||
rect: CGRect(x: 392, y: 282, width: 159, height: 88), color: orange)
|
||||
drawText("격리는 DB만의 문제가 아닙니다", CGRect(x: margin, y: 404, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
let tenantAxes: [(String, String)] = [
|
||||
("식별", "이 요청이 어느 회사 것인지 신뢰할 수 있게 결정"),
|
||||
("인가", "그 사용자가 해당 회사 자원에 접근 가능한지 확인"),
|
||||
("데이터", "모든 조회·수정 쿼리에 회사 범위 적용"),
|
||||
("설정", "회사별 정책·브랜딩·카드 선택"),
|
||||
("캐시", "캐시 key에 tenant를 포함해 회사 간 충돌 방지"),
|
||||
("자원", "한 회사의 과부하가 다른 회사에 미치는 영향 제한"),
|
||||
]
|
||||
for (i, t) in tenantAxes.enumerated() {
|
||||
let x = margin + CGFloat(i % 2) * 260
|
||||
let y = CGFloat(444 + (i / 2) * 74)
|
||||
rounded(CGRect(x: x, y: y, width: 247, height: 58), radius: 9,
|
||||
fill: purple.withAlphaComponent(0.06), stroke: purple.withAlphaComponent(0.25))
|
||||
drawText(t.0, CGRect(x: x + 12, y: y + 10, width: 52, height: 18), size: 10,
|
||||
color: purple, weight: .bold)
|
||||
drawText(t.1, CGRect(x: x + 66, y: y + 9, width: 168, height: 38), size: 8.5, color: ink)
|
||||
}
|
||||
callout("가장 흔한 사고", "쿼리의 WHERE tenant_id 조건 누락, 공유 캐시 key에 tenant_id 누락, 사용자가 body로 보낸 tenant_id를 그대로 신뢰하는 경우입니다. 그래서 tenant context를 요청 초기에 확정하고 자동 전달하는 구조가 중요합니다.",
|
||||
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 10 multitenancy concept
|
||||
beginPage(ctx, page: 10)
|
||||
sectionTitle("5", "멀티테넌시", "하나의 시스템을 여러 회사가 쓰되, 설정과 데이터의 경계를 지키는 구조", color: purple)
|
||||
callout("쉬운 비유", "한 오피스 빌딩을 여러 회사가 함께 사용하지만 출입카드가 자기 회사 층만 열어주는 구조입니다. 서버는 공유하되, 요청마다 ‘어느 회사의 요청인지’를 먼저 확정해야 합니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: purple)
|
||||
drawText("요청이 회사별 엔진을 찾는 과정", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("HTTP 요청", "X-Tenant-ID", rect: CGRect(x: 45, y: 310, width: 100, height: 68), color: blue)
|
||||
node("Middleware", "존재·등록 검증", rect: CGRect(x: 195, y: 310, width: 105, height: 68), color: purple)
|
||||
node("request.state", "tenant_id 보관", rect: CGRect(x: 350, y: 310, width: 105, height: 68), color: cyan)
|
||||
arrow(CGPoint(x: 145, y: 344), CGPoint(x: 195, y: 344), color: blue)
|
||||
arrow(CGPoint(x: 300, y: 344), CGPoint(x: 350, y: 344), color: purple)
|
||||
node("Registry", "회사별 엔진 선택", rect: CGRect(x: 195, y: 445, width: 105, height: 68), color: orange)
|
||||
node("TenantEngine", "회사별 정책·카드", rect: CGRect(x: 350, y: 445, width: 105, height: 68), color: green)
|
||||
arrow(CGPoint(x: 402, y: 378), CGPoint(x: 275, y: 445), color: cyan)
|
||||
arrow(CGPoint(x: 300, y: 479), CGPoint(x: 350, y: 479), color: orange)
|
||||
callout("보안 핵심", "tenant_id를 요청 body에서 받으면 사용자가 다른 회사 ID를 넣어 위조할 수 있습니다. 이 프로젝트는 헤더/경로에서 결정한 값을 middleware가 request.state에 넣고, 뒤의 코드가 그것만 사용합니다.",
|
||||
rect: CGRect(x: margin, y: 558, width: 507, height: 105), color: red)
|
||||
comparison("멀티테넌시 경계가 약하면", "A회사 요청이 B회사 카드·설정·협상 엔진을 사용할 수 있습니다. 이는 단순 버그가 아니라 데이터 유출 사고입니다.",
|
||||
"현재 방식", "요청 시작점에서 tenant를 검증하고, Registry가 해당 회사의 설정과 엔진을 해석합니다.",
|
||||
y: 687, color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 11 multitenancy code
|
||||
beginPage(ctx, page: 11)
|
||||
sectionTitle("5", "멀티테넌시 — 실제 코드", "식별 → 검증 → request.state 전달 → 회사별 엔진 선택의 4단계", color: purple)
|
||||
codeBox("Middleware: tenant를 요청 경계에서 확정",
|
||||
"agent/router/middleware/tenant_middleware.py · lines 35–69",
|
||||
"""
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
|
||||
if not tenant_id:
|
||||
return JSONResponse(status_code=400, ...)
|
||||
|
||||
if not tenant_registry.is_registered(tenant_id):
|
||||
return JSONResponse(status_code=404, ...)
|
||||
|
||||
request.state.tenant_id = tenant_id
|
||||
return await call_next(request)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 205), accent: purple)
|
||||
codeBox("Dependency: 검증된 tenant로 엔진 조회",
|
||||
"agent/router/deps.py · lines 13–21",
|
||||
"""
|
||||
async def get_tenant_engine(request: Request) -> TenantEngine:
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
if not tenant_id:
|
||||
raise EXCEPTION_TENANT_HEADER_MISSING
|
||||
return await tenant_registry.get_engine(tenant_id)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 360, width: 507, height: 165), accent: cyan)
|
||||
codeBox("Registry: 프로세스 메모리에서 회사별 엔진 재사용",
|
||||
"agent/tenancy/registry.py · lines 85–98",
|
||||
"""
|
||||
cached = self._engines.get(tenant_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
async with self._locks[tenant_id]:
|
||||
cached = self._engines.get(tenant_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
engine = await self._build(tenant_id)
|
||||
self._engines[tenant_id] = engine
|
||||
return engine
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 550, width: 507, height: 205), accent: orange)
|
||||
endPage(ctx)
|
||||
|
||||
// 12 — Cache consistency: concept first
|
||||
beginPage(ctx, page: 12)
|
||||
sectionTitle("8", "캐시 정합성 — 개념부터", "비싼 계산·DB·외부 호출의 결과를 가까운 곳에 복사해 재사용하는 기술", color: red)
|
||||
drawText("기본 용어", CGRect(x: margin, y: 126, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
let cacheTerms: [(String, String, NSColor)] = [
|
||||
("Hit", "캐시에 값이 있어 원본을 읽지 않음", green),
|
||||
("Miss", "값이 없어 원본을 읽고 캐시를 채움", blue),
|
||||
("TTL", "값이 자동 만료될 때까지의 시간", orange),
|
||||
("Stale", "원본은 바뀌었지만 캐시는 옛 값인 상태", red),
|
||||
]
|
||||
for (i, t) in cacheTerms.enumerated() {
|
||||
let x = margin + CGFloat(i % 2) * 260
|
||||
let y = CGFloat(165 + (i / 2) * 75)
|
||||
callout(t.0, t.1, rect: CGRect(x: x, y: y, width: 247, height: 60), color: t.2)
|
||||
}
|
||||
drawText("대표적인 읽기·쓰기 패턴", CGRect(x: margin, y: 338, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
callout("Cache-aside", "앱이 캐시를 먼저 조회하고 miss이면 DB를 읽어 캐시에 저장합니다. 단순하고 가장 흔하지만 무효화를 앱이 책임집니다.",
|
||||
rect: CGRect(x: margin, y: 378, width: 247, height: 92), color: blue)
|
||||
callout("Write-through", "쓰기 때 캐시와 원본을 함께 갱신합니다. 읽기는 안정적이지만 쓰기 지연과 두 저장소의 부분 실패를 다뤄야 합니다.",
|
||||
rect: CGRect(x: 304, y: 378, width: 247, height: 92), color: purple)
|
||||
callout("Write-behind", "캐시에 먼저 쓰고 DB는 나중에 반영합니다. 빠르지만 캐시 장애 시 데이터 유실 위험이 있어 업무 원본에는 신중해야 합니다.",
|
||||
rect: CGRect(x: margin, y: 486, width: 247, height: 92), color: orange)
|
||||
callout("Negative cache", "‘결과 없음’도 잠깐 저장합니다. 반복 실패 비용을 줄이지만 너무 긴 TTL은 새로 생긴 데이터를 늦게 발견하게 합니다.",
|
||||
rect: CGRect(x: 304, y: 486, width: 247, height: 92), color: green)
|
||||
drawText("캐시에서 자주 생기는 문제", CGRect(x: margin, y: 610, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
callout("Invalidation", "원본 변경 후 어떤 key를 언제 삭제·갱신할지 결정하기 어렵습니다.",
|
||||
rect: CGRect(x: margin, y: 650, width: 159, height: 82), color: red)
|
||||
callout("Stampede", "인기 key가 만료되는 순간 많은 요청이 동시에 DB로 몰립니다.",
|
||||
rect: CGRect(x: 218, y: 650, width: 159, height: 82), color: orange)
|
||||
callout("Key 설계", "tenant·버전 등이 빠지면 서로 다른 데이터가 같은 key를 공유합니다.",
|
||||
rect: CGRect(x: 392, y: 650, width: 159, height: 82), color: purple)
|
||||
endPage(ctx)
|
||||
|
||||
// 13 cache concept
|
||||
beginPage(ctx, page: 13)
|
||||
sectionTitle("8", "캐시 정합성과 무효화", "빠른 복사본이 원본과 다른 값을 갖지 않도록 관리하는 문제", color: red)
|
||||
callout("캐시는 복사본", "도서관 검색대의 메모가 캐시이고, 원본 장부가 DB라고 생각하면 쉽습니다. 메모는 빠르지만 오래된 정보일 수 있습니다. 정합성이란 메모와 장부가 의미상 같은 상태를 유지하는 것입니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 95), color: red)
|
||||
drawText("앵커링 값의 저장·조회 순서", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("1. DB 저장", "조정 이력 COMMIT", rect: CGRect(x: 46, y: 310, width: 118, height: 70), color: purple)
|
||||
node("2. Redis SET", "최신값 + TTL 7일", rect: CGRect(x: 238, y: 310, width: 118, height: 70), color: red)
|
||||
node("3. 다음 조회", "Redis 우선", rect: CGRect(x: 430, y: 310, width: 118, height: 70), color: blue)
|
||||
arrow(CGPoint(x: 164, y: 345), CGPoint(x: 238, y: 345), color: purple)
|
||||
arrow(CGPoint(x: 356, y: 345), CGPoint(x: 430, y: 345), color: red)
|
||||
drawText("Redis 실패", CGRect(x: 240, y: 417, width: 110, height: 18), size: 9, color: red, weight: .bold)
|
||||
arrow(CGPoint(x: 297, y: 380), CGPoint(x: 297, y: 465), color: red)
|
||||
node("DB Fallback", "업무는 계속", rect: CGRect(x: 238, y: 465, width: 118, height: 70), color: green)
|
||||
callout("stale 데이터란?", "DB에는 새 값 60이 저장됐는데 Redis SET이 실패해 캐시에 옛 값 55가 남은 상태입니다. 캐시 miss와 달리 값이 존재하므로 더 위험합니다. TTL과 주간 re-SET으로 회복합니다.",
|
||||
rect: CGRect(x: margin, y: 574, width: 507, height: 95), color: orange)
|
||||
comparison("캐시만 믿으면", "Redis 장애가 업무 장애가 되고, 오래된 값이 실제 제안가를 왜곡할 수 있습니다. Redis 유실 시 원본도 사라집니다.",
|
||||
"원본 DB + 파생 캐시", "Redis 장애 시 DB를 읽고, TTL과 reconciliation으로 오래된 복사본을 교정합니다.",
|
||||
y: 693, color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 14 cache code
|
||||
beginPage(ctx, page: 14)
|
||||
sectionTitle("8", "캐시 정합성 — 실제 코드", "Cache-aside, TTL, DB fallback, reconciliation이 한 세트로 작동합니다.", color: red)
|
||||
codeBox("Cache-aside: Redis miss → DB → Redis backfill",
|
||||
"schedules/anchoring/src/anchoring/reader.py · lines 33–42",
|
||||
"""
|
||||
cached = await get_value(company_id, supplier_type, price_range)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
value = await get_latest_adjusted_value(
|
||||
db, company_id, supplier_type, price_range
|
||||
)
|
||||
if value is None:
|
||||
value = get_base_anchoring_value(price_range)
|
||||
|
||||
await set_value(..., value, nx=True)
|
||||
return value
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 225), accent: red)
|
||||
codeBox("Redis 장애는 cache miss로 취급",
|
||||
"schedules/anchoring/src/anchoring/redis_client.py · lines 69–84",
|
||||
"""
|
||||
try:
|
||||
raw = await _client.get(anchor_key(...))
|
||||
if raw is None:
|
||||
return None
|
||||
return int(raw)
|
||||
except Exception as ex:
|
||||
_note_failure("get", anchor_key(...), ex)
|
||||
return None # 호출측이 DB fallback
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 380, width: 507, height: 180), accent: orange)
|
||||
callout("두 겹의 회복 장치", "TTL 7일은 오래된 키가 영원히 남는 것을 막습니다. 주간 reconciliation은 DB의 최신 조정값을 Redis에 다시 SET하여, DB commit 뒤 Redis 갱신에 실패했던 값도 교정합니다.",
|
||||
rect: CGRect(x: margin, y: 585, width: 507, height: 100), color: red)
|
||||
callout("현재 견적 생성 경로의 예외", "Negodata의 실제 견적 생성은 Redis를 사용하지 않고 PostgreSQL의 anchoring.current_values View를 일괄 조회합니다. Redis는 현재 anchoring 배치 내부 캐시입니다.",
|
||||
rect: CGRect(x: margin, y: 705, width: 507, height: 72), color: cyan)
|
||||
endPage(ctx)
|
||||
|
||||
// 15 — Scheduler and batch: concept first
|
||||
beginPage(ctx, page: 15)
|
||||
sectionTitle("9", "스케줄러·배치 — 개념부터", "시간 규칙으로 작업을 시작하고, 많은 데이터를 사용자 요청 밖에서 처리하는 방식", color: green)
|
||||
callout("둘의 차이", "스케줄러는 ‘언제 실행할지’를 결정합니다. 배치 Job은 ‘무엇을 어떻게 처리할지’를 구현합니다. CronTrigger가 알람시계라면 close_expired_quotations는 알람이 울렸을 때 수행할 실제 업무입니다.",
|
||||
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: green)
|
||||
drawText("Job의 생명주기", CGRect(x: margin, y: 248, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("Trigger", "실행 시각 도달", rect: CGRect(x: 45, y: 297, width: 94, height: 66), color: green)
|
||||
node("Select", "처리 대상 조회", rect: CGRect(x: 181, y: 297, width: 94, height: 66), color: blue)
|
||||
node("Process", "개별 업무 수행", rect: CGRect(x: 317, y: 297, width: 94, height: 66), color: orange)
|
||||
node("Checkpoint", "결과·진행점 기록", rect: CGRect(x: 453, y: 297, width: 94, height: 66), color: purple)
|
||||
arrow(CGPoint(x: 139, y: 330), CGPoint(x: 181, y: 330), color: green)
|
||||
arrow(CGPoint(x: 275, y: 330), CGPoint(x: 317, y: 330), color: blue)
|
||||
arrow(CGPoint(x: 411, y: 330), CGPoint(x: 453, y: 330), color: orange)
|
||||
drawText("운영에서 반드시 결정할 것", CGRect(x: margin, y: 405, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
let jobIssues: [(String, String, NSColor)] = [
|
||||
("중복 실행", "이전 Job이 안 끝났는데 다음 시각이 오면?", red),
|
||||
("Misfire", "서버가 꺼져 실행 시각을 놓쳤다면?", orange),
|
||||
("부분 실패", "100건 중 73번째가 실패하면 어디부터 재개?", purple),
|
||||
("재시도", "즉시 재시도, 다음 tick, 운영자 재처리 중 무엇?", blue),
|
||||
("멱등성", "같은 대상을 다시 처리해도 중복 효과가 없는가?", green),
|
||||
("관측성", "처리량·실패 대상·소요시간을 로그와 지표로 남기는가?", cyan),
|
||||
]
|
||||
for (i, j) in jobIssues.enumerated() {
|
||||
let x = margin + CGFloat(i % 2) * 260
|
||||
let y = CGFloat(445 + (i / 2) * 73)
|
||||
callout(j.0, j.1, rect: CGRect(x: x, y: y, width: 247, height: 58), color: j.2)
|
||||
}
|
||||
callout("스케줄러만으로 정확성은 보장되지 않음", "max_instances=1은 한 프로세스 안의 중복을 막을 뿐입니다. 서버가 여러 대면 각 서버가 Job을 실행할 수 있으므로 DB 조건부 갱신, 분산 락, 전용 Worker 같은 추가 방어가 필요합니다.",
|
||||
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
|
||||
endPage(ctx)
|
||||
|
||||
// 16 scheduler concept
|
||||
beginPage(ctx, page: 16)
|
||||
sectionTitle("9", "스케줄러와 배치 안정성", "사용자 요청 없이 정해진 시간마다 반복 업무를 수행하는 백그라운드 실행", color: green)
|
||||
callout("쉬운 비유", "API가 손님이 주문할 때 움직이는 직원이라면, 스케줄러는 매 5분마다 마감 시간이 지난 주문을 확인하는 당직자입니다. 사람이 요청하지 않아도 시간이 되면 일을 시작합니다.",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: green)
|
||||
drawText("5분 tick의 세 가지 작업", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||||
size: 14, color: navy, weight: .bold)
|
||||
node("CronTrigger", "매 5분", rect: CGRect(x: 48, y: 315, width: 105, height: 68), color: green)
|
||||
node("잡 ①", "기한 지난 견적 마감", rect: CGRect(x: 225, y: 280, width: 135, height: 62), color: orange)
|
||||
node("잡 ②", "협상 완료 견적 마감", rect: CGRect(x: 225, y: 365, width: 135, height: 62), color: purple)
|
||||
node("잡 ③", "LPS 결과 증분 반영", rect: CGRect(x: 225, y: 450, width: 135, height: 62), color: cyan)
|
||||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 311), color: green)
|
||||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 396), color: green)
|
||||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 481), color: green)
|
||||
node("PostgreSQL", "조건부 처리·기록", rect: CGRect(x: 430, y: 365, width: 115, height: 72), color: blue)
|
||||
arrow(CGPoint(x: 360, y: 311), CGPoint(x: 430, y: 385), color: orange)
|
||||
arrow(CGPoint(x: 360, y: 396), CGPoint(x: 430, y: 401), color: purple)
|
||||
arrow(CGPoint(x: 360, y: 481), CGPoint(x: 430, y: 420), color: cyan)
|
||||
callout("중복 실행 방지 장치", "SCHEDULER_ENABLED=1인 프로세스 하나만 잡을 등록합니다. 각 잡은 max_instances=1이고, 밀린 실행은 coalesce=True로 한 번만 실행합니다. 그래도 다중 서버 가능성을 고려해 DB의 조건부 UPDATE가 마지막 방어선입니다.",
|
||||
rect: CGRect(x: margin, y: 565, width: 507, height: 115), color: green)
|
||||
comparison("안정 장치가 없으면", "서버 Worker 수만큼 같은 잡이 실행되고, 같은 견적을 여러 번 마감하거나 알림을 중복 발송할 수 있습니다.",
|
||||
"현재 방식", "실행 프로세스 제한 + 잡 중복 제한 + DB 동시성 가드를 겹쳐 사용합니다.",
|
||||
y: 704, color: green)
|
||||
endPage(ctx)
|
||||
|
||||
// 17 scheduler code
|
||||
beginPage(ctx, page: 17)
|
||||
sectionTitle("9", "스케줄러·배치 — 실제 코드", "‘언제 실행할지’와 ‘무엇을 안전하게 처리할지’를 분리합니다.", color: green)
|
||||
codeBox("APScheduler 등록: 5분, 중복 방지, 지연 허용",
|
||||
"negodata/backend/scheduler/__init__.py · lines 37–69",
|
||||
"""
|
||||
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
_scheduler.add_job(
|
||||
jobs.close_expired_quotations,
|
||||
CronTrigger(minute="*/5"),
|
||||
id="close_expired_quotations",
|
||||
coalesce=True, # 밀린 실행은 1번만
|
||||
misfire_grace_time=600, # 10분 내 지연 실행 허용
|
||||
max_instances=1, # 같은 잡 동시 실행 금지
|
||||
)
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 130, width: 507, height: 215), accent: green)
|
||||
codeBox("Job: 대상 조회와 개별 마감 처리를 분리",
|
||||
"negodata/backend/scheduler/jobs.py · lines 39–61",
|
||||
"""
|
||||
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: crud.list_due_for_close(s, now),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return 0
|
||||
|
||||
results = await _close_each(service, qt_ids)
|
||||
return sum(results.values())
|
||||
""",
|
||||
rect: CGRect(x: margin, y: 370, width: 507, height: 190), accent: cyan)
|
||||
callout("멱등성(idempotency)", "같은 잡을 두 번 실행해도 최종 결과가 한 번 실행한 것과 같도록 만드는 성질입니다. 대상 조회가 중복될 수 있어도 claim_for_close의 조건부 UPDATE가 두 번째 처리를 rowcount=0으로 막습니다.",
|
||||
rect: CGRect(x: margin, y: 585, width: 507, height: 105), color: purple)
|
||||
callout("실패한 tick은 어떻게 되나요?", "LPS 동기화는 watermark 기반 증분 처리라 실패한 회차의 데이터가 다음 5분 tick에서 다시 대상이 됩니다. 스케줄러 자체 재시도보다 데이터 설계를 통해 회복합니다.",
|
||||
rect: CGRect(x: margin, y: 710, width: 507, height: 72), color: green)
|
||||
endPage(ctx)
|
||||
|
||||
// 18 combined scenario
|
||||
beginPage(ctx, page: 18)
|
||||
drawText("다섯 기술이 한 장면에서 만나는 순간", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||||
size: 23, color: navy, weight: .bold)
|
||||
drawText("예: 협상이 모두 끝난 견적을 스케줄러가 자동 마감하는 동안 담당자가 수동 마감을 클릭했다.",
|
||||
CGRect(x: margin, y: 88, width: 507, height: 30), size: 10.5, color: muted)
|
||||
let rows: [(String, String, NSColor)] = [
|
||||
("1", "멀티테넌시: 요청의 X-Tenant-ID로 어느 회사의 협상 엔진과 데이터인지 결정", purple),
|
||||
("2", "분산 시스템: Backend가 Agent를 HTTP로 호출할 때 timeout과 부분 실패를 구분", blue),
|
||||
("3", "스케줄러: 5분 tick이 동일 견적을 마감 대상으로 발견", green),
|
||||
("4", "동시성 제어: 수동 요청과 스케줄러 중 조건부 UPDATE를 먼저 성공한 쪽만 처리", orange),
|
||||
("5", "트랜잭션: 관련 상태 변경을 commit하거나, 실패하면 rollback", cyan),
|
||||
("6", "캐시 정합성: 원본 DB commit 후 파생 캐시를 갱신하고 실패 시 다음 회차에 회복", red),
|
||||
]
|
||||
for (i, item) in rows.enumerated() {
|
||||
let y = CGFloat(145 + i * 91)
|
||||
rounded(CGRect(x: margin, y: y, width: 507, height: 70), radius: 12,
|
||||
fill: item.2.withAlphaComponent(0.08), stroke: item.2.withAlphaComponent(0.30))
|
||||
rounded(CGRect(x: 58, y: y + 15, width: 40, height: 40), radius: 20,
|
||||
fill: item.2, stroke: nil)
|
||||
drawText(item.0, CGRect(x: 58, y: y + 24, width: 40, height: 20), size: 13,
|
||||
color: .white, weight: .bold, align: .center)
|
||||
drawText(item.1, CGRect(x: 116, y: y + 15, width: 415, height: 42), size: 10.2,
|
||||
color: ink, weight: .medium, lineSpacing: 3)
|
||||
if i < rows.count - 1 {
|
||||
arrow(CGPoint(x: 78, y: y + 70), CGPoint(x: 78, y: y + 90), color: item.2)
|
||||
}
|
||||
}
|
||||
callout("핵심 관점", "어려운 백엔드 기술은 ‘라이브러리 이름’보다 경계와 실패를 다루는 방법입니다. 네트워크 경계, 회사 경계, 트랜잭션 경계, 캐시의 원본 경계를 명확하게 설계하는 것이 핵심입니다.",
|
||||
rect: CGRect(x: margin, y: 708, width: 507, height: 78), color: navy)
|
||||
endPage(ctx)
|
||||
|
||||
// 19 glossary
|
||||
beginPage(ctx, page: 19)
|
||||
drawText("초보자를 위한 한 줄 사전", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||||
size: 24, color: navy, weight: .bold)
|
||||
let glossary: [(String, String)] = [
|
||||
("분산 시스템", "여러 프로세스·서버가 네트워크로 협력하는 시스템"),
|
||||
("부분 실패", "전체 중 일부 서비스만 실패한 상태"),
|
||||
("Timeout", "응답을 무한히 기다리지 않고 정해진 시간에 포기하는 제한"),
|
||||
("Best-effort", "실패해도 핵심 업무는 성공시키는 보조 작업 정책"),
|
||||
("트랜잭션", "여러 DB 변경을 모두 성공 또는 모두 취소하는 작업 단위"),
|
||||
("Rollback", "실패했을 때 트랜잭션의 변경을 되돌리는 것"),
|
||||
("Race condition", "실행 순서에 따라 결과가 달라지는 동시성 문제"),
|
||||
("원자적 연산", "중간 상태가 보이지 않도록 한 번에 처리되는 연산"),
|
||||
("멀티테넌시", "한 시스템을 여러 고객사가 격리된 상태로 공유하는 구조"),
|
||||
("Cache-aside", "캐시를 먼저 보고, miss이면 원본 조회 후 캐시를 채우는 패턴"),
|
||||
("TTL", "캐시 값이 자동 만료되기까지의 시간"),
|
||||
("Stale", "원본보다 오래되어 현재와 맞지 않는 캐시 상태"),
|
||||
("Invalidation", "원본 변경 시 캐시를 삭제하거나 무효화하는 것"),
|
||||
("Reconciliation", "원본과 복사본을 비교·재적재해 다시 맞추는 작업"),
|
||||
("Scheduler", "정해진 시간 규칙에 따라 작업을 실행하는 도구"),
|
||||
("Batch", "사용자 요청과 별개로 데이터 묶음을 주기적으로 처리하는 작업"),
|
||||
("멱등성", "같은 작업을 반복해도 최종 결과가 달라지지 않는 성질"),
|
||||
]
|
||||
for (i, g) in glossary.enumerated() {
|
||||
let col = i < 9 ? 0 : 1
|
||||
let row = col == 0 ? i : i - 9
|
||||
let x = margin + CGFloat(col) * 260
|
||||
let y = CGFloat(128 + row * 69)
|
||||
drawText(g.0, CGRect(x: x, y: y, width: 230, height: 19), size: 10.5,
|
||||
color: [blue, orange, purple, red, green][i % 5], weight: .bold)
|
||||
drawText(g.1, CGRect(x: x, y: y + 23, width: 230, height: 36), size: 8.8,
|
||||
color: ink, lineSpacing: 2)
|
||||
}
|
||||
callout("추천 복습 순서", "트랜잭션·동시성 → 캐시 정합성 → 스케줄러 → 멀티테넌시 → 분산 시스템 순으로 다시 보면, 작은 DB 작업에서 전체 서비스 구조로 이해가 확장됩니다.",
|
||||
rect: CGRect(x: margin, y: 718, width: 507, height: 74), color: blue)
|
||||
endPage(ctx)
|
||||
|
||||
ctx.closePDF()
|
||||
print(outPath)
|
||||
@ -57,8 +57,6 @@ export interface RefreshTokenResponse {
|
||||
export interface Branding {
|
||||
service_name?: string
|
||||
logo_url?: string
|
||||
primary_color?: string
|
||||
email_header?: string
|
||||
helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
|
||||
}
|
||||
|
||||
@ -67,7 +65,6 @@ export interface SessionBrandingResponse {
|
||||
result: ApiResult
|
||||
service_name: string
|
||||
logo_url: string
|
||||
primary_color: string
|
||||
helpdesk?: string[]
|
||||
}
|
||||
|
||||
|
||||
@ -43,7 +43,6 @@ export function usePreLoginBranding(): Branding | null {
|
||||
const next: Branding = {
|
||||
service_name: res.service_name || undefined,
|
||||
logo_url: res.logo_url || undefined,
|
||||
primary_color: res.primary_color || undefined,
|
||||
helpdesk: res.helpdesk?.length ? res.helpdesk : undefined,
|
||||
}
|
||||
if (!next.service_name && !next.logo_url && !next.helpdesk) return
|
||||
|
||||
@ -110,7 +110,8 @@ function ItemInfo() {
|
||||
<div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0">
|
||||
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
|
||||
{renderRow(fieldLabel('item.code', '상품코드'), item_code)}
|
||||
{renderRow(fieldLabel('item.price', '단가'), formattedPrice)}
|
||||
{/* 값은 회사가 고른 협상 기준가(item_price), 호칭은 공급사 화면 고정 용어 — 회사 용어 사전을 타지 않는다. */}
|
||||
{renderRow('공급가', formattedPrice)}
|
||||
{renderRow(fieldLabel('item.model_name', '모델명'), item_model_name)}
|
||||
{renderRow(fieldLabel('item.manufacturer', '제조사'), item_maker_name)}
|
||||
{renderRow(fieldLabel('item.moq', '최소주문수량'), item_min_order_quantity)}
|
||||
|
||||
@ -44,7 +44,7 @@ export function MainLayout({
|
||||
header,
|
||||
children,
|
||||
}: MainLayoutProps) {
|
||||
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
|
||||
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입 — 색은 솔루션 고정
|
||||
const panes = (
|
||||
<>
|
||||
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
|
||||
|
||||
@ -4,7 +4,7 @@ import { Logo } from '@/components'
|
||||
import { LoginForm, usePreLoginBranding } from '@/features/auth'
|
||||
|
||||
export function LoginPage() {
|
||||
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩을 먼저 그린다.
|
||||
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩(서비스명·로고)을 먼저 그린다.
|
||||
const branding = usePreLoginBranding()
|
||||
|
||||
// 이미 로그인된 상태면 목록으로
|
||||
|
||||
@ -55,6 +55,10 @@ class IQuotationCRUD(ABC):
|
||||
async def get_company_settings(self, cdb: AsyncSession, user_id) -> dict:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_company_brand(self, cdb: AsyncSession, user_id) -> Tuple[str, dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType:
|
||||
pass
|
||||
@ -408,6 +412,24 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return {}
|
||||
|
||||
async def get_company_brand(self, cdb: AsyncSession, user_id):
|
||||
# 이 유저가 속한 회사의 (이름, settings). 초청 메일 헤더 기본값이 회사명이라 이름까지 같이 읽는다.
|
||||
try:
|
||||
query = (
|
||||
select(companies.name, companies.settings)
|
||||
.select_from(users)
|
||||
.join(companies, companies.company_id == users.company_id)
|
||||
.where(users.user_id == user_id, companies.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err != ErrorType.SUCCESS or not rows:
|
||||
return "", {}
|
||||
return rows[0][0] or "", rows[0][1] or {}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return "", {}
|
||||
|
||||
async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||
"""item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력."""
|
||||
try:
|
||||
|
||||
@ -12,4 +12,5 @@ openpyxl
|
||||
httpx
|
||||
apscheduler>=3.10
|
||||
azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email)
|
||||
aiohttp>=3.9 # azure aio 클라이언트(EmailClient.aio)의 HTTP 전송 계층
|
||||
aiosmtplib>=3.0 # 초청 메일 폴백(SMTP)
|
||||
|
||||
@ -70,3 +70,13 @@ class Req_UpdateCompanySettings(CompanySettingsProtocol):
|
||||
|
||||
class Res_CompanySettings(Res_WebPacketProtocol):
|
||||
settings: Optional[dict] = None
|
||||
|
||||
|
||||
class Req_PreviewInviteEmail(CompanySettingsProtocol):
|
||||
# 저장 전 편집값으로 초청 메일을 렌더한다. settings.branding 서브키 중 email_* 만 쓴다.
|
||||
branding: dict = {}
|
||||
|
||||
|
||||
class Res_PreviewInviteEmail(Res_WebPacketProtocol):
|
||||
subject: str = ""
|
||||
html: str = ""
|
||||
|
||||
@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from common.models.gmodel import UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner
|
||||
from services.company_settings_service import CompanySettingsService
|
||||
from .protocol import Req_UpdateCompanySettings, Res_CompanySettings
|
||||
from .protocol import Req_PreviewInviteEmail, Req_UpdateCompanySettings, Res_CompanySettings, Res_PreviewInviteEmail
|
||||
|
||||
# 회사별 커스터마이징 설정. 조회=로그인 유저 전원(앱 부팅 시 브랜딩/라벨 로드), 수정=최고관리자(OWNER) 전용.
|
||||
router = APIRouter(prefix="/v1/company/settings", tags=["CompanySettings"], responses={404: {"description": "Not found"}})
|
||||
@ -19,3 +19,11 @@ async def update_settings(
|
||||
req: Req_UpdateCompanySettings, service: CompanySettingsService = Depends(), owner: UserInfo = Depends(RequireOwner)
|
||||
):
|
||||
return RemoveNoneResponse(await service.update_settings(owner.company_id, req))
|
||||
|
||||
|
||||
# 저장 전 편집값을 그대로 렌더한다(쓰기 없음) — 로그인만 요구. 회사명은 토큰의 회사로 채운다.
|
||||
@router.post(path="/email-preview", response_model=Res_PreviewInviteEmail, summary="협상 초청 메일 미리보기")
|
||||
async def preview_invite_email(
|
||||
req: Req_PreviewInviteEmail, service: CompanySettingsService = Depends(), user: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.preview_invite_email(user.company_id, req))
|
||||
|
||||
@ -3,6 +3,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from common.enums import UserRole
|
||||
from common.models.gmodel import PageParams, UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.quotation import QuotationService
|
||||
@ -42,7 +43,9 @@ async def list_quotations(
|
||||
mine: bool = Query(False, description="내 견적만 보기(작성자=로그인 유저)"),
|
||||
pg: PageParams = Depends(),
|
||||
):
|
||||
owner = user_info.user_id if mine else None
|
||||
# 일반(USER)은 mine 파라미터와 무관하게 본인 견적만 — 타 담당자 견적 조회는 OWNER 이상만.
|
||||
force_mine = (user_info.role or 0) < UserRole.OWNER.value
|
||||
owner = user_info.user_id if (mine or force_mine) else None
|
||||
return RemoveNoneResponse(await service.list_quotations(user_info.company_id, owner, search, status, type, start_from, start_to, pg))
|
||||
|
||||
|
||||
@ -84,12 +87,12 @@ async def regenerate_quotation(
|
||||
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
|
||||
@router.get(path="/{qt_id}/status", response_model=Res_QuotationStatus, summary="견적 상태 조회")
|
||||
async def get_quotation_status(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_status(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.get_status(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.get(path="/{qt_id}/sessions", response_model=Res_QuotationSessions, summary="참여현황")
|
||||
async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.list_sessions(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.list_sessions(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송")
|
||||
@ -99,12 +102,12 @@ async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), u
|
||||
|
||||
@router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세")
|
||||
async def get_session_chat(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.list_chats(str(session_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.list_chats(str(session_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.get(path="/session/{session_id}/target-breakdown", response_model=Res_TargetBreakdown, summary="세션 목표가 산정내역")
|
||||
async def get_target_breakdown(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_target_breakdown(str(session_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.get_target_breakdown(str(session_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송")
|
||||
@ -114,12 +117,12 @@ async def notify_session(session_id: UUID, service: QuotationService = Depends()
|
||||
|
||||
@router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과")
|
||||
async def get_quotation_result(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_result(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.get_result(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.get(path="/{qt_id}/cards", response_model=Res_QuotationCards, summary="견적 사용 카드")
|
||||
async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.list_cards(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.list_cards(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제")
|
||||
@ -130,4 +133,4 @@ async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), u
|
||||
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----
|
||||
@router.get(path="/{qt_id}", response_model=Res_Quotation, summary="견적 조회")
|
||||
async def get_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_quotation(str(qt_id), user_info.company_id))
|
||||
return RemoveNoneResponse(await service.get_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import companies
|
||||
from common.enums import DBWRType, ErrorType
|
||||
from common.utils.gtime import GTime
|
||||
from config.server_configs import web_server_config
|
||||
from crud.user_crud import IUserCRUD, UserCRUD
|
||||
from router.v1.company.protocol import Req_UpdateCompanySettings, Res_CompanySettings
|
||||
from router.v1.company.protocol import (
|
||||
Req_PreviewInviteEmail,
|
||||
Req_UpdateCompanySettings,
|
||||
Res_CompanySettings,
|
||||
Res_PreviewInviteEmail,
|
||||
)
|
||||
from services.email import build_invite_email
|
||||
|
||||
|
||||
class CompanySettingsService:
|
||||
@ -43,3 +52,25 @@ class CompanySettingsService:
|
||||
return res
|
||||
res.settings = req.settings
|
||||
return res
|
||||
|
||||
async def preview_invite_email(self, company_id: str, req: Req_PreviewInviteEmail) -> Res_PreviewInviteEmail:
|
||||
"""저장 전 branding 편집값을 샘플 견적 데이터로 렌더한다(발송 없음).
|
||||
실제 발송(services/quotation/invites.py)과 같은 build_invite_email 을 타므로 미리보기=실물.
|
||||
회사명은 헤더 기본값이라 실발송과 동일하게 채워 보여준다."""
|
||||
res = Res_PreviewInviteEmail()
|
||||
_, company = await DB_SESSION_MNG.execute_lambda(
|
||||
companies.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.user_crud.get_company(s, uuid.UUID(company_id)),
|
||||
)
|
||||
base = (web_server_config.nego_chat_url or "").rstrip("/")
|
||||
res.subject, res.html, _ = build_invite_email(
|
||||
supplier_name="샘플 협력사",
|
||||
quotation_name="복사용지 A4 80g 외 2건",
|
||||
qt_number="EST-2026-0801",
|
||||
end_time=GTime.UTC() + timedelta(days=3),
|
||||
chat_url=f"{base}/chat?session_id=00000000-0000-0000-0000-000000000000",
|
||||
company_name=(company.name if company else "") or "",
|
||||
branding=req.branding,
|
||||
)
|
||||
return res
|
||||
|
||||
@ -9,6 +9,7 @@ HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from html import escape
|
||||
@ -21,6 +22,12 @@ from config.server_configs import mail_config
|
||||
|
||||
_KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
# 회사 CI(settings.branding) 미설정 시 기본값 — 설정 화면 placeholder 와 같은 값이어야 한다.
|
||||
# 색 기본값은 공급사 협상 포털(frontend --brand-600)과 동일 — 메일에서 포털로 이어지는 화면이 한 브랜드로 보이게.
|
||||
_DEFAULT_EMAIL_COLOR = "#3182f6"
|
||||
_DEFAULT_EMAIL_HEADER = "NEGODATA"
|
||||
_DEFAULT_EMAIL_GREETING = "아래 견적 건의 협상에 참여해 주세요."
|
||||
|
||||
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
|
||||
_TEMPLATE_DIR = Path(__file__).parent / "email_templates"
|
||||
_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8"))
|
||||
@ -96,17 +103,27 @@ def build_invite_email(
|
||||
qt_number: str,
|
||||
end_time: datetime | None,
|
||||
chat_url: str,
|
||||
email_header: str | None = None,
|
||||
company_name: str = "",
|
||||
branding: dict | None = None,
|
||||
) -> tuple[str, str, str]:
|
||||
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
|
||||
|
||||
목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지).
|
||||
공급사는 링크로 협상 화면에 진입해 입찰한다.
|
||||
branding = companies.settings.branding — 헤더는 회사 CI(logo_url·service_name)를
|
||||
그대로 쓴다(메일 전용 이중 설정 금지). 메일 전용 키는 인사 문구(email_greeting) 하나뿐.
|
||||
헤더는 로고 있으면 로고, 없으면 service_name → 회사명(company_name) → NEGODATA 텍스트 —
|
||||
공급사에겐 솔루션명보다 발주사가 보여야 한다. 색은 회사별 커스텀 없이 솔루션 기본색 고정.
|
||||
레이아웃은 고정이고 값만 갈아끼우므로, 어떤 값을 넣어도 메일이 깨지지 않는다.
|
||||
"""
|
||||
b = branding or {}
|
||||
sp = supplier_name or "협력사"
|
||||
deadline = _fmt_deadline(end_time) or "미정"
|
||||
subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청"
|
||||
|
||||
header_name = (b.get("service_name") or "").strip() or (company_name or "").strip() or _DEFAULT_EMAIL_HEADER
|
||||
greeting = (b.get("email_greeting") or "").strip() or _DEFAULT_EMAIL_GREETING
|
||||
|
||||
# HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력).
|
||||
html = _INVITE_HTML.substitute(
|
||||
supplier_name=escape(sp),
|
||||
@ -114,14 +131,27 @@ def build_invite_email(
|
||||
qt_number=escape(qt_number),
|
||||
deadline=escape(deadline),
|
||||
chat_url=escape(chat_url),
|
||||
email_header=escape(email_header or "NEGODATA"),
|
||||
brand_color=_DEFAULT_EMAIL_COLOR,
|
||||
header_content=_header_content(header_name, (b.get("logo_url") or "").strip()),
|
||||
email_greeting=escape(greeting),
|
||||
)
|
||||
|
||||
text = (
|
||||
f"{sp} 담당자님, 아래 견적 건의 협상에 참여해 주세요.\n\n"
|
||||
f"{sp} 담당자님, {greeting}\n\n"
|
||||
f" - 견적명: {quotation_name}\n"
|
||||
f" - 견적번호: {qt_number}\n"
|
||||
f" - 협상 마감: {deadline}\n\n"
|
||||
f"협상 참여 링크: {chat_url}\n"
|
||||
f"협상 참여 링크: {chat_url}\n\n"
|
||||
f"— negotium B2B 구매협상 솔루션\n"
|
||||
)
|
||||
return subject, html, text
|
||||
|
||||
|
||||
def _header_content(name: str, logo_url: str) -> str:
|
||||
"""헤더(흰 배경) 내용물 — 회사 CI 로고가 있으면 이미지(회사명은 alt 로), 없으면 회사명 텍스트. escape 는 여기서 끝낸다."""
|
||||
if logo_url:
|
||||
return (
|
||||
f'<img src="{escape(logo_url)}" alt="{escape(name)}" height="32" '
|
||||
f'style="display:block;height:32px;max-width:220px;border:0;margin:0 auto;" />'
|
||||
)
|
||||
return f'<span style="color:#191f28;font-size:18px;font-weight:800;letter-spacing:-0.4px;">{escape(name)}</span>'
|
||||
|
||||
@ -1,37 +1,43 @@
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;margin:0;padding:24px 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f2f4f6;margin:0;padding:24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="width:480px;max-width:480px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;font-family:'Apple SD Gothic Neo',-apple-system,'Segoe UI',Roboto,'Malgun Gothic',sans-serif;">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="width:480px;max-width:480px;background-color:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e5e8eb;font-family:Pretendard,'Apple SD Gothic Neo',-apple-system,'Segoe UI',Roboto,'Malgun Gothic',sans-serif;">
|
||||
|
||||
<!-- 헤더 바 -->
|
||||
<!-- 브랜드 액센트 — 회사 CI 색(primary_color) -->
|
||||
<tr>
|
||||
<td style="background-color:#2563eb;padding:18px 28px;">
|
||||
<span style="color:#ffffff;font-size:16px;font-weight:700;letter-spacing:1px;">$email_header</span>
|
||||
<td height="4" bgcolor="$brand_color" style="height:4px;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
|
||||
<!-- 헤더 — 회사 CI(로고 있으면 로고, 없으면 회사명) + 솔루션 태그라인. 챗 포털 로그인 락업과 같은 구성 -->
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 28px 0 28px;">
|
||||
$header_content
|
||||
<p style="margin:10px 0 0 0;font-size:12px;color:#8b95a1;">negotium B2B 구매협상 솔루션</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 본문 -->
|
||||
<tr>
|
||||
<td style="padding:32px 28px 4px 28px;">
|
||||
<h1 style="margin:0 0 10px 0;font-size:20px;font-weight:700;color:#111827;">협상 참여 요청</h1>
|
||||
<p style="margin:0 0 24px 0;font-size:14px;line-height:1.7;color:#4b5563;">
|
||||
<strong style="color:#111827;">$supplier_name</strong> 담당자님,<br>
|
||||
아래 견적 건의 협상에 참여해 주세요.
|
||||
<h1 style="margin:0 0 10px 0;font-size:20px;font-weight:700;letter-spacing:-0.4px;color:#191f28;">협상 참여 요청</h1>
|
||||
<p style="margin:0 0 24px 0;font-size:14px;line-height:1.7;color:#4e5968;">
|
||||
<strong style="color:#191f28;">$supplier_name</strong> 담당자님,<br>
|
||||
$email_greeting
|
||||
</p>
|
||||
|
||||
<!-- 견적 정보 카드 -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;border:1px solid #eef0f3;border-radius:10px;margin-bottom:28px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;border:1px solid #e5e8eb;border-radius:12px;margin-bottom:28px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#6b7280;width:88px;">견적명</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#111827;font-weight:600;text-align:right;">$quotation_name</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#8b95a1;width:88px;">견적명</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#191f28;font-weight:600;text-align:right;">$quotation_name</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">견적번호</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#374151;text-align:right;border-top:1px solid #eef0f3;">$qt_number</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#8b95a1;border-top:1px solid #f2f4f6;">견적번호</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#333d4b;text-align:right;border-top:1px solid #f2f4f6;">$qt_number</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">협상 마감</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#dc2626;font-weight:700;text-align:right;border-top:1px solid #eef0f3;">$deadline</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#8b95a1;border-top:1px solid #f2f4f6;">협상 마감</td>
|
||||
<td style="padding:14px 16px;font-size:13px;color:#e71c3b;font-weight:700;text-align:right;border-top:1px solid #f2f4f6;">$deadline</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@ -41,9 +47,8 @@
|
||||
<td align="center">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#2563eb" style="border-radius:8px;">
|
||||
<!-- TODO: 세션별 협상링크 연결 시 href 를 $$chat_url 로 복원 -->
|
||||
<a href="https://nego.o2o.kr" style="display:inline-block;padding:14px 34px;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">협상 참여하기 →</a>
|
||||
<td align="center" bgcolor="$brand_color" style="border-radius:12px;">
|
||||
<a href="$chat_url" style="display:inline-block;padding:14px 34px;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:12px;">협상 참여하기 →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -56,15 +61,14 @@
|
||||
<!-- 푸터 -->
|
||||
<tr>
|
||||
<td style="padding:18px 28px 26px 28px;">
|
||||
<p style="margin:0;font-size:11px;line-height:1.7;color:#9ca3af;border-top:1px solid #f0f1f3;padding-top:16px;">
|
||||
<p style="margin:0;font-size:11px;line-height:1.7;color:#8b95a1;border-top:1px solid #f2f4f6;padding-top:16px;">
|
||||
버튼이 열리지 않으면 아래 링크를 복사해 접속하세요.<br>
|
||||
<a href="https://nego.o2o.kr" style="color:#6b7280;word-break:break-all;">https://nego.o2o.kr</a>
|
||||
<a href="$chat_url" style="color:#4e5968;word-break:break-all;">$chat_url</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<p style="margin:16px 0 0 0;font-size:11px;color:#b0b4bb;font-family:sans-serif;">본 메일은 협상 견적 시스템에서 자동 발송되었습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@ -95,12 +95,12 @@ class InvitesMixin:
|
||||
async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list:
|
||||
"""targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고
|
||||
성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED)."""
|
||||
# 회사 브랜딩(초청 메일 헤더) 한 번 조회 — 견적당 동일.
|
||||
settings = await DB_SESSION_MNG.execute_lambda(
|
||||
# 회사명·브랜딩(메일 헤더·색·로고·문구) 한 번 조회 — 견적당 동일. 회사명은 헤더 기본값.
|
||||
company_name, settings = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_company_settings(s, quotation.user_id),
|
||||
lambda s: self.quotation_crud.get_company_brand(s, quotation.user_id),
|
||||
)
|
||||
email_header = (settings.get("branding") or {}).get("email_header")
|
||||
branding = settings.get("branding") or {}
|
||||
sent_ids = []
|
||||
for sess, sp_name, email in targets:
|
||||
subject, html, text = build_invite_email(
|
||||
@ -109,7 +109,8 @@ class InvitesMixin:
|
||||
qt_number=quotation.number,
|
||||
end_time=quotation.end_time,
|
||||
chat_url=self._session_chat_url(sess.session_id),
|
||||
email_header=email_header,
|
||||
company_name=company_name,
|
||||
branding=branding,
|
||||
)
|
||||
try:
|
||||
await send_email(email, subject, html, text)
|
||||
|
||||
@ -180,7 +180,7 @@ class PricingMixin:
|
||||
anchors[(iid, sid)] = (value, ap)
|
||||
return anchors
|
||||
|
||||
async def get_target_breakdown(self, session_id: str, company_id=None) -> Res_TargetBreakdown:
|
||||
async def get_target_breakdown(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_TargetBreakdown:
|
||||
"""목표가 모달의 산정내역 응답.
|
||||
|
||||
저장된 목표가·앵커링가는 그대로 내려주고, 후보 목록은 _candidates 로 다시 계산한다.
|
||||
@ -195,7 +195,7 @@ class PricingMixin:
|
||||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||||
return res
|
||||
sess = got[0]
|
||||
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
|
||||
err_type, quotation = await self._fetch(sess.quotation_id, company_id, user_id, role)
|
||||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||||
return res
|
||||
|
||||
@ -23,9 +23,11 @@ from router.v1.quotation.protocol import (
|
||||
|
||||
|
||||
class QueriesMixin:
|
||||
async def _fetch(self, qt_id: uuid.UUID, company_id=None):
|
||||
async def _fetch(self, qt_id: uuid.UUID, company_id=None, user_id=None, role=None):
|
||||
"""견적 단건 조회. (ErrorType, quotation|None) 반환.
|
||||
company_id 가 주어지면 회사 스코프(작성자 회사) 가드 — 남의 회사 견적은 NOT_FOUND. 내부/스케줄러 호출은 None."""
|
||||
company_id 가 주어지면 회사 스코프(작성자 회사) 가드 — 남의 회사 견적은 NOT_FOUND. 내부/스케줄러 호출은 None.
|
||||
user_id 가 주어지면 조회 게이팅 — 일반(USER)은 본인 견적만, 남의 견적은 존재를 숨긴다(NOT_FOUND).
|
||||
변경 액션(삭제·마감 등)은 안내 문구가 필요해 user_id 없이 부르고 각자 ACCOUNT_FORBIDDEN 게이트를 탄다."""
|
||||
err_type, quotation = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
@ -33,6 +35,8 @@ class QueriesMixin:
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||||
return ErrorType.QUOTATION_NOT_FOUND, None
|
||||
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
|
||||
return ErrorType.QUOTATION_NOT_FOUND, None
|
||||
return ErrorType.SUCCESS, quotation
|
||||
|
||||
async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
|
||||
@ -90,9 +94,9 @@ class QueriesMixin:
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
async def get_quotation(self, qt_id: str, company_id=None) -> Res_Quotation:
|
||||
async def get_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
|
||||
res = Res_Quotation()
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
@ -121,9 +125,9 @@ class QueriesMixin:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
async def get_status(self, qt_id: str, company_id=None) -> Res_QuotationStatus:
|
||||
async def get_status(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_QuotationStatus:
|
||||
res = Res_QuotationStatus()
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
@ -132,9 +136,9 @@ class QueriesMixin:
|
||||
res.message = "ok"
|
||||
return res
|
||||
|
||||
async def get_result(self, qt_id: str, company_id=None) -> Res_QuotationResult:
|
||||
async def get_result(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_QuotationResult:
|
||||
res = Res_QuotationResult()
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
|
||||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
@ -147,10 +151,10 @@ class QueriesMixin:
|
||||
res.result_count = 0
|
||||
return res
|
||||
|
||||
async def list_sessions(self, qt_id: str, company_id=None) -> Res_QuotationSessions:
|
||||
async def list_sessions(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_QuotationSessions:
|
||||
res = Res_QuotationSessions()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
err_type, quotation = await self._fetch(qt_uuid, company_id)
|
||||
err_type, quotation = await self._fetch(qt_uuid, company_id, user_id, role)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
@ -193,7 +197,7 @@ class QueriesMixin:
|
||||
res.total = len(res.sessions)
|
||||
return res
|
||||
|
||||
async def list_chats(self, session_id: str, company_id=None) -> Res_SessionChat:
|
||||
async def list_chats(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_SessionChat:
|
||||
res = Res_SessionChat()
|
||||
sess_uuid = uuid.UUID(session_id)
|
||||
res.session_id = sess_uuid
|
||||
@ -208,7 +212,7 @@ class QueriesMixin:
|
||||
if g_err != ErrorType.SUCCESS or got is None:
|
||||
res.result.SetResult(g_err if g_err != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||||
return res
|
||||
guard_err, _ = await self._fetch(got[0].quotation_id, company_id)
|
||||
guard_err, _ = await self._fetch(got[0].quotation_id, company_id, user_id, role)
|
||||
if guard_err != ErrorType.SUCCESS:
|
||||
res.result.SetResult(guard_err)
|
||||
return res
|
||||
@ -242,10 +246,10 @@ class QueriesMixin:
|
||||
]
|
||||
return res
|
||||
|
||||
async def list_cards(self, qt_id: str, company_id=None) -> Res_QuotationCards:
|
||||
async def list_cards(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_QuotationCards:
|
||||
res = Res_QuotationCards()
|
||||
qt_uuid = uuid.UUID(qt_id)
|
||||
err_type, quotation = await self._fetch(qt_uuid, company_id)
|
||||
err_type, quotation = await self._fetch(qt_uuid, company_id, user_id, role)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
@ -280,5 +284,37 @@ class QueriesMixin:
|
||||
memo=memo,
|
||||
)
|
||||
)
|
||||
|
||||
# 봇은 버전 카드셋 밖에서도 카드를 고르므로(RL 런타임 미제한) 채팅의 사용 카드(chats.card_id)가
|
||||
# 버전 목록에 없으면 화면·JSON 내보내기의 카드 매칭이 전부 null 이 된다 → 실사용 카드를 합쳐 내려준다.
|
||||
# 부가 조회라 실패 시 버전 카드만으로 응답한다(목록의 참여수 집계와 같은 best-effort 패턴).
|
||||
used_err, used_rows = await DB_SESSION_MNG.execute_lambda(
|
||||
chats.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.list_used_cards(s, qt_uuid),
|
||||
)
|
||||
if used_err == ErrorType.SUCCESS:
|
||||
seen = {c.session_card_id for c in cards}
|
||||
for chat, card_pk, number, name, script, edit, condition, memo in used_rows:
|
||||
# card_pk 미해석(카드 삭제 등)이면 건너뛴다 — 매칭할 카탈로그 정보가 없다.
|
||||
if card_pk is None or card_pk in seen:
|
||||
continue
|
||||
seen.add(card_pk)
|
||||
is_wild = chat.card_type == 2
|
||||
cards.append(
|
||||
QuotationCardData(
|
||||
session_card_id=card_pk,
|
||||
qt_id=quotation.qt_id,
|
||||
nego_card_id=None if is_wild else card_pk,
|
||||
wild_card_id=card_pk if is_wild else None,
|
||||
type=chat.card_type,
|
||||
number=number,
|
||||
name=name,
|
||||
script=script,
|
||||
edit_script=edit,
|
||||
condition=condition,
|
||||
memo=memo,
|
||||
)
|
||||
)
|
||||
res.cards = cards
|
||||
return res
|
||||
|
||||
74
negodata/backend/tests/test_invite_email_preview.py
Normal file
74
negodata/backend/tests/test_invite_email_preview.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""협상 초청 메일 미리보기(/v1/company/settings/email-preview) 테스트 — 회사 CI 슬롯 렌더 확인.
|
||||
|
||||
- 실제 발송(services/quotation/invites.py)과 같은 build_invite_email 을 타므로 미리보기 검증 = 발송물 검증.
|
||||
- 헤더는 회사 CI(logo_url·service_name)를 그대로 쓰고 색은 솔루션 고정 — 메일 전용 설정은 인사 문구뿐.
|
||||
- 색은 hex 만 통과한다(이상값이 style 을 깨거나 CSS 주입되는 것 방지).
|
||||
"""
|
||||
|
||||
PREVIEW_URL = "/v1/company/settings/email-preview"
|
||||
|
||||
|
||||
async def test_preview_requires_login(client):
|
||||
"""검증: 토큰 없이 미리보기 호출.
|
||||
기대결과: HTTP 401/403 으로 거부(로그인 필수)."""
|
||||
r = await client.post(PREVIEW_URL, json={"branding": {}})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
async def test_preview_default_branding(client, auth_headers):
|
||||
"""검증: branding 비운 채 미리보기 호출.
|
||||
기대결과: 헤더는 회사명 텍스트(테스트사, NEGODATA 아님)·기본색(#3182f6)·기본 인사 문구로 렌더되고 제목이 채워진다."""
|
||||
h = await auth_headers("previewer1")
|
||||
r = await client.post(PREVIEW_URL, json={"branding": {}}, headers=h)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "협상 참여 요청" in body["subject"]
|
||||
html = body["html"]
|
||||
assert "테스트사" in html # conftest company_id 픽스처의 회사명
|
||||
assert "NEGODATA" not in html
|
||||
assert "#3182f6" in html
|
||||
assert "아래 견적 건의 협상에 참여해 주세요." in html
|
||||
assert "negotium B2B 구매협상 솔루션" in html # 발주사 CI 아래 솔루션 태그라인(고정)
|
||||
|
||||
|
||||
async def test_preview_ci_branding(client, auth_headers):
|
||||
"""검증: 회사 CI(서비스명·로고)와 인사 문구를 채워 미리보기 호출(브랜드 색을 넣어도 무시).
|
||||
기대결과: 헤더는 로고 이미지(서비스명은 alt), 색은 회사 값과 무관하게 솔루션 기본색으로 렌더."""
|
||||
h = await auth_headers("previewer2")
|
||||
branding = {
|
||||
"service_name": "아이좋아네고",
|
||||
"logo_url": "https://cdn.example.com/ci.png",
|
||||
"primary_color": "#f551a0", # 제거된 기능 — 값을 보내도 반영되지 않아야 한다
|
||||
"email_greeting": "협상에 초대합니다.",
|
||||
}
|
||||
r = await client.post(PREVIEW_URL, json={"branding": branding}, headers=h)
|
||||
assert r.status_code == 200
|
||||
html = r.json()["html"]
|
||||
assert '<img src="https://cdn.example.com/ci.png"' in html
|
||||
assert 'alt="아이좋아네고"' in html
|
||||
assert "#f551a0" not in html
|
||||
assert "#3182f6" in html
|
||||
assert "협상에 초대합니다." in html
|
||||
|
||||
|
||||
async def test_preview_service_name_without_logo(client, auth_headers):
|
||||
"""검증: 로고 없이 서비스명만 채워 미리보기 호출.
|
||||
기대결과: 헤더는 서비스명 텍스트로 렌더되고 img 태그는 없다."""
|
||||
h = await auth_headers("previewer3")
|
||||
r = await client.post(PREVIEW_URL, json={"branding": {"service_name": "IMK 구매협상"}}, headers=h)
|
||||
assert r.status_code == 200
|
||||
html = r.json()["html"]
|
||||
assert "IMK 구매협상" in html
|
||||
assert "<img" not in html
|
||||
|
||||
|
||||
async def test_preview_bad_color_falls_back(client, auth_headers):
|
||||
"""검증: hex 가 아닌 primary_color(CSS 주입 꼴)로 미리보기 호출.
|
||||
기대결과: 입력값은 HTML 에 실리지 않고 기본색(#3182f6)으로 렌더된다."""
|
||||
h = await auth_headers("previewer4")
|
||||
bad = "red;background-image:url(https://evil)"
|
||||
r = await client.post(PREVIEW_URL, json={"branding": {"primary_color": bad}}, headers=h)
|
||||
assert r.status_code == 200
|
||||
html = r.json()["html"]
|
||||
assert bad not in html
|
||||
assert "#3182f6" in html
|
||||
142
negodata/backend/tests/test_quotation_read_gating.py
Normal file
142
negodata/backend/tests/test_quotation_read_gating.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""견적 조회 게이팅 + 사용 카드 매칭 보강.
|
||||
|
||||
- 조회 게이팅: 일반(USER)은 본인 견적만 — 목록은 서버가 강제 필터, 상세는 남의 견적의 존재 자체를
|
||||
숨긴다(QUOTATION_NOT_FOUND). OWNER 이상은 회사 전체 조회. 판정은 common.authz.is_owner_or_admin 공용.
|
||||
- 사용 카드: 봇은 버전 카드셋 밖에서도 카드를 고르므로(RL 런타임 미제한) 버전 목록에 없는
|
||||
실사용 카드(chats.card_id)도 /cards 응답에 합쳐 내려준다 — 협상로그 화면·JSON 내보내기의
|
||||
카드 매칭 실패(card=null) 복구.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import ChatSender, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole
|
||||
from crud.quotation_crud import QuotationCRUD
|
||||
from services.quotation import QuotationService
|
||||
|
||||
PAST = datetime(2020, 1, 1)
|
||||
|
||||
|
||||
# ===== 목록 — HTTP e2e: 같은 회사, 담당자 2명 =====
|
||||
async def test_list_forced_to_own_for_user_role(client, auth_headers, db_engine):
|
||||
"""검증: 같은 회사 담당자 A/B 가 견적을 하나씩 가진 상태에서 일반유저 B 가 목록 조회(mine 미지정).
|
||||
기대결과: B 본인 견적만 내려온다 — 서버가 mine 을 강제해 A 견적은 목록에서 제외."""
|
||||
ha = await auth_headers("read_user_a")
|
||||
hb = await auth_headers("read_user_b")
|
||||
qt_a = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_a"), number="READ-A")
|
||||
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_b"), number="READ-B")
|
||||
|
||||
numbers = [q["number"] for q in (await client.get("/v1/quotation/list", headers=hb)).json()["quotations"]]
|
||||
assert numbers == ["READ-B"]
|
||||
|
||||
# 소유자 A 본인은 당연히 자기 견적을 본다(대조군)
|
||||
assert (await client.get(f"/v1/quotation/{qt_a}", headers=ha)).json()["result"]["success"] is True
|
||||
|
||||
|
||||
async def test_list_full_company_for_owner_role(client, auth_headers, db_engine):
|
||||
"""검증: 최고관리자(OWNER)가 목록 조회(mine 미지정).
|
||||
기대결과: 회사 전체 — 다른 담당자들 견적까지 모두 내려온다."""
|
||||
await auth_headers("read_user_c")
|
||||
hadmin = await auth_headers("read_admin", role=UserRole.OWNER.value)
|
||||
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_c"), number="READ-C")
|
||||
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_admin"), number="READ-D")
|
||||
|
||||
numbers = {q["number"] for q in (await client.get("/v1/quotation/list", headers=hadmin)).json()["quotations"]}
|
||||
assert numbers == {"READ-C", "READ-D"}
|
||||
|
||||
|
||||
# ===== 상세 — HTTP e2e: 남의 견적 =====
|
||||
async def test_detail_hidden_from_other_user(client, auth_headers, db_engine):
|
||||
"""검증: A 의 견적 상세(/{qt_id}·/sessions·/cards)를 같은 회사 일반유저 B 가 조회.
|
||||
기대결과: 모두 거부 — 존재를 숨기는 QUOTATION_NOT_FOUND(권한 코드가 아니라 미존재로 응답)."""
|
||||
await auth_headers("read_owner")
|
||||
hb = await auth_headers("read_other")
|
||||
qt = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_owner"), number="READ-HIDE")
|
||||
|
||||
for path in (f"/v1/quotation/{qt}", f"/v1/quotation/{qt}/sessions", f"/v1/quotation/{qt}/cards"):
|
||||
body = (await client.get(path, headers=hb)).json()
|
||||
assert body["result"]["success"] is False
|
||||
assert body["result"]["code"] == ErrorType.QUOTATION_NOT_FOUND.value
|
||||
|
||||
|
||||
async def test_detail_visible_to_owner_role(client, auth_headers, db_engine):
|
||||
"""검증: A 의 견적 상세를 같은 회사 최고관리자(OWNER)가 조회.
|
||||
기대결과: 성공 — 소유자가 아니어도 OWNER 는 조회 가능."""
|
||||
await auth_headers("read_owner2")
|
||||
hadmin = await auth_headers("read_admin2", role=UserRole.OWNER.value)
|
||||
qt = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_owner2"), number="READ-SHOW")
|
||||
|
||||
body = (await client.get(f"/v1/quotation/{qt}", headers=hadmin)).json()
|
||||
assert body["result"]["success"] is True
|
||||
assert body["quotation"]["number"] == "READ-SHOW"
|
||||
|
||||
|
||||
# ===== 사용 카드 — 서비스 직접: 버전 밖 카드 매칭 =====
|
||||
async def test_list_cards_includes_used_card_outside_version(db_engine):
|
||||
"""검증: 버전 카드셋엔 없는 카드를 채팅에서 사용(chats.card_used_yn=true, card_id=카드 PK)한 견적의 카드 조회.
|
||||
기대결과: 응답에 그 카드가 session_card_id=카드 PK 로 포함 — 화면·내보내기 매칭이 성립한다."""
|
||||
user = uuid.uuid4()
|
||||
qt = await _seed_quotation(db_engine, user_id=user, number="CARD-USED")
|
||||
card = uuid.uuid4()
|
||||
session = uuid.uuid4()
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("INSERT INTO nego_cards (nego_card_id, user_id, name, number, script, usage_type) "
|
||||
"VALUES (:c, :u, '실사용카드', 'NGC-X1', '멘트', 1)"),
|
||||
{"c": card, "u": user},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO sessions "
|
||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||
" target_price, status, end_time) VALUES "
|
||||
"(:sid, :qt, :item, :sup, 'CARD-USED', 1, :type, 0, :st, :t)"
|
||||
),
|
||||
{"sid": session, "qt": qt, "item": uuid.uuid4(), "sup": uuid.uuid4(),
|
||||
"type": QuotationType.REQUOTE.value, "st": SessionStatus.DONE.value, "t": PAST},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO chats (chat_id, session_id, card_id, seq, sender, target_price, card_used_yn, card_type) "
|
||||
"VALUES (:cid, :sid, :card, 1, :sender, 0, true, 1)"
|
||||
),
|
||||
{"cid": uuid.uuid4(), "sid": session, "card": card, "sender": ChatSender.BOT.value},
|
||||
)
|
||||
|
||||
res = await QuotationService(QuotationCRUD()).list_cards(str(qt))
|
||||
|
||||
assert res.result.success is True
|
||||
got = {str(c.session_card_id): c for c in res.cards}
|
||||
assert str(card) in got
|
||||
assert got[str(card)].number == "NGC-X1"
|
||||
assert got[str(card)].nego_card_id == card # nego 카드로 분류(type=1)
|
||||
|
||||
|
||||
# ===== 헬퍼 =====
|
||||
async def _user_id(engine, login_id) -> uuid.UUID:
|
||||
async with engine.begin() as conn:
|
||||
return (await conn.execute(
|
||||
text("SELECT user_id FROM users WHERE id = :i"), {"i": login_id}
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
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
|
||||
BIN
negodata/docs/anchoring-customization-design.pdf
Normal file
BIN
negodata/docs/anchoring-customization-design.pdf
Normal file
Binary file not shown.
@ -25,8 +25,10 @@ import type {
|
||||
|
||||
import type {
|
||||
HTTPValidationError,
|
||||
ReqPreviewInviteEmail,
|
||||
ReqUpdateCompanySettings,
|
||||
ResCompanySettings
|
||||
ResCompanySettings,
|
||||
ResPreviewInviteEmail
|
||||
} from '.././model';
|
||||
|
||||
import { customFetch } from '../../mutator/custom-fetch';
|
||||
@ -191,4 +193,68 @@ export const useUpdateSettings = <TError = void | HTTPValidationError,
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 협상 초청 메일 미리보기
|
||||
*/
|
||||
export const previewInviteEmail = (
|
||||
reqPreviewInviteEmail: ReqPreviewInviteEmail,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResPreviewInviteEmail>(
|
||||
{url: `/v1/company/settings/email-preview`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: reqPreviewInviteEmail, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getPreviewInviteEmailMutationOptions = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof previewInviteEmail>>, TError,{data: ReqPreviewInviteEmail}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof previewInviteEmail>>, TError,{data: ReqPreviewInviteEmail}, TContext> => {
|
||||
|
||||
const mutationKey = ['previewInviteEmail'];
|
||||
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 previewInviteEmail>>, {data: ReqPreviewInviteEmail}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return previewInviteEmail(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type PreviewInviteEmailMutationResult = NonNullable<Awaited<ReturnType<typeof previewInviteEmail>>>
|
||||
export type PreviewInviteEmailMutationBody = ReqPreviewInviteEmail
|
||||
export type PreviewInviteEmailMutationError = void | HTTPValidationError
|
||||
|
||||
/**
|
||||
* @summary 협상 초청 메일 미리보기
|
||||
*/
|
||||
export const usePreviewInviteEmail = <TError = void | HTTPValidationError,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof previewInviteEmail>>, TError,{data: ReqPreviewInviteEmail}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof previewInviteEmail>>,
|
||||
TError,
|
||||
{data: ReqPreviewInviteEmail},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getPreviewInviteEmailMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
|
||||
@ -203,6 +203,8 @@ export * from './reqCreateSupplierManagerEmail';
|
||||
export * from './reqCreateSupplierManagerName';
|
||||
export * from './reqCreateSupplierTotalRevenue';
|
||||
export * from './reqLogin';
|
||||
export * from './reqPreviewInviteEmail';
|
||||
export * from './reqPreviewInviteEmailBranding';
|
||||
export * from './reqRegenerateQuotation';
|
||||
export * from './reqRegenerateQuotationCardIds';
|
||||
export * from './reqRegenerateQuotationDoneCeilingRate';
|
||||
@ -344,6 +346,8 @@ export * from './resNotificationRead';
|
||||
export * from './resNotificationReadMsg';
|
||||
export * from './resNotifySessions';
|
||||
export * from './resNotifySessionsMsg';
|
||||
export * from './resPreviewInviteEmail';
|
||||
export * from './resPreviewInviteEmailMsg';
|
||||
export * from './resQuotation';
|
||||
export * from './resQuotationCards';
|
||||
export * from './resQuotationCardsMsg';
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ReqPreviewInviteEmailBranding } from './reqPreviewInviteEmailBranding';
|
||||
|
||||
export interface ReqPreviewInviteEmail {
|
||||
branding?: ReqPreviewInviteEmailBranding;
|
||||
}
|
||||
@ -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 ReqPreviewInviteEmailBranding = { [key: string]: unknown };
|
||||
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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 { ResPreviewInviteEmailMsg } from './resPreviewInviteEmailMsg';
|
||||
|
||||
export interface ResPreviewInviteEmail {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResPreviewInviteEmailMsg;
|
||||
subject?: string;
|
||||
html?: string;
|
||||
}
|
||||
@ -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 ResPreviewInviteEmailMsg = string | null;
|
||||
@ -21,18 +21,6 @@ import NotificationsPage from '../pages/notifications';
|
||||
import OnboardingPage from '../pages/onboarding';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과
|
||||
// 내부 import()가 통째로 트리셰이킹되어 번들/라우트에 포함되지 않는다.
|
||||
...(import.meta.env.DEV
|
||||
? [
|
||||
{
|
||||
path: 'dev/design',
|
||||
lazy: async () => ({
|
||||
Component: (await import('../pages/dev/dev-design')).default,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
index: true,
|
||||
loader: () => redirect('/dashboard'),
|
||||
|
||||
@ -180,11 +180,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
||||
{branding.logoUrl ? (
|
||||
<img src={branding.logoUrl} alt={branding.serviceName} className="h-4 max-w-24 object-contain" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-4 rounded-[5px] bg-primary"
|
||||
style={branding.primaryColor ? { backgroundColor: branding.primaryColor } : undefined}
|
||||
/>
|
||||
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
|
||||
)}
|
||||
{branding.serviceName}
|
||||
</Typography>
|
||||
@ -291,7 +287,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
||||
{/* Main Container Wrapper */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex flex-col transition-all duration-300',
|
||||
// min-w-0: flex 아이템은 기본 min-width:auto 라 넓은 콘텐츠(다컬럼 표·차트)가
|
||||
// 래퍼를 뷰포트 밖으로 밀어낸다 — 이걸 끊어야 표 내부 overflow-x-auto 가 스크롤로 받는다.
|
||||
'flex-1 min-w-0 flex flex-col transition-all duration-300',
|
||||
// 모바일: 사이드바가 오버레이라 패딩 없음 / 데스크톱: 사이드바 폭만큼 확보
|
||||
isSidebarOpen ? 'md:pl-60' : 'md:pl-16'
|
||||
)}
|
||||
@ -585,7 +583,8 @@ function NavItem({
|
||||
function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<Typography as="div" variant="caption" className="hidden lg:block">
|
||||
{/* 기준일시·관리자는 xl 부터 — lg 구간(1024~1280)은 우측 1fr 이 ~240px 라 글자가 세로로 깨진다. */}
|
||||
<Typography as="div" variant="caption" className="hidden xl:block whitespace-nowrap">
|
||||
기준일시: <span className="font-semibold text-foreground">{today}</span>
|
||||
</Typography>
|
||||
|
||||
@ -603,8 +602,8 @@ function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-border hidden lg:block" />
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
<div className="h-4 w-px bg-border hidden xl:block" />
|
||||
<div className="hidden xl:flex items-center gap-2 whitespace-nowrap">
|
||||
<Typography as="span" variant="caption">관리자:</Typography>
|
||||
<Typography as="span" variant="caption" className="font-semibold text-foreground">
|
||||
{user?.name} ({user?.loginId})
|
||||
|
||||
@ -236,7 +236,7 @@ const ZONE_STYLE: Record<NegotiationZoneTone, { marker: string; active: string;
|
||||
marker: 'bg-primary',
|
||||
active: 'bg-primary/10',
|
||||
text: 'text-primary',
|
||||
track: '#2563eb',
|
||||
track: '#5E6AD2',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,332 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ImagePlus, X, Loader2 } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { useScrollLock } from '@/lib/useScrollLock';
|
||||
import { uploadItemImage, updateItem } from '@/api/generated/item/item';
|
||||
import { useAuthStore, canManage } from '@/stores/auth';
|
||||
import type { Product } from '../types';
|
||||
|
||||
// 로컬 이미지 일괄 업로드 — 파일명(확장자 제외)을 상품코드로 매칭해 업로드하고 상품에 연결한다.
|
||||
// 엑셀 양식과 무관한 독립 흐름: 기존 단건 업로드(/v1/item/image)와 상품 수정(image_url)을 파일별로 반복한다.
|
||||
// (협력사·카드 엑셀과 같은 프론트 루프 패턴 — 신규 API 없음)
|
||||
|
||||
// 상품 등록 폼이 이미지 미입력 시 넣는 기본(placeholder) 이미지 — 실제 이미지로 치지 않는다.
|
||||
const PLACEHOLDER_IMAGE_MARK = 'images.unsplash.com/photo-1593941707882';
|
||||
|
||||
type RowIssue =
|
||||
| 'unmatched' // 파일명과 일치하는 상품코드 없음
|
||||
| 'dup_code' // 같은 코드의 상품이 여러 건 — 어느 상품인지 특정 불가
|
||||
| 'dup_file' // 같은 코드의 파일이 여러 개 — 앞 파일만 유효
|
||||
| 'forbidden'; // 남의 상품(수정권한 없음 — 본인∪최고관리자만)
|
||||
|
||||
type RowState = 'ready' | 'skip' | 'uploading' | 'done' | 'failed';
|
||||
|
||||
type MatchRow = {
|
||||
file: File;
|
||||
base: string; // 파일명에서 확장자를 뗀 값 = 상품코드 후보
|
||||
product?: Product;
|
||||
issue?: RowIssue;
|
||||
hasImage: boolean; // 기존 이미지 보유(placeholder 제외) — 덮어쓰기 옵션 판정용
|
||||
state: RowState;
|
||||
reason?: string; // failed 사유
|
||||
};
|
||||
|
||||
type Phase = 'select' | 'running' | 'done';
|
||||
|
||||
export function ImageBulkUploadModal({
|
||||
open,
|
||||
loadProducts,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
loadProducts: () => Promise<Product[]>; // 전체 상품(페이지 밖 포함) — 매칭·권한 판정용
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useScrollLock(open);
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role));
|
||||
|
||||
const [rows, setRows] = useState<MatchRow[]>([]);
|
||||
const [phase, setPhase] = useState<Phase>('select');
|
||||
const [overwrite, setOverwrite] = useState(true); // 대부분 placeholder 이미지라 기본 덮어쓰기
|
||||
const [matching, setMatching] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 선택/드롭 → 전체 상품을 받아 파일명=상품코드 매칭표를 만든다(업로드는 아직 안 한다).
|
||||
const handleFiles = async (files: FileList | File[]) => {
|
||||
const picked = [...files].filter((f) => f.type.startsWith('image/'));
|
||||
if (picked.length === 0) {
|
||||
showToast('이미지 파일이 없습니다. (jpg/png 등 이미지만 지원)', 'error');
|
||||
return;
|
||||
}
|
||||
setMatching(true);
|
||||
try {
|
||||
const products = await loadProducts();
|
||||
setRows(buildRows(picked, products, myUserId, isSuperAdmin));
|
||||
setPhase('select');
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '상품 목록 조회에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadTargets = rows.filter((r) => isUploadable(r, overwrite));
|
||||
const skippedByImage = rows.filter((r) => !r.issue && r.product && !overwrite && r.hasImage);
|
||||
|
||||
// 업로드 실행 — 파일별 순차: 이미지 업로드 → 받은 URL 을 상품 수정으로 저장. 실패해도 멈추지 않는다.
|
||||
const handleRun = async () => {
|
||||
setPhase('running');
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
for (const row of rows) {
|
||||
if (!isUploadable(row, overwrite)) continue;
|
||||
setRowState(setRows, row.file, { state: 'uploading' });
|
||||
try {
|
||||
const up = await uploadItemImage({ file: row.file as unknown as string });
|
||||
if (up.result?.success === false) throw new Error(up.result.desc || '이미지 업로드 실패');
|
||||
if (!up.image_url) throw new Error('업로드 응답에 URL 이 없습니다.');
|
||||
const res = await updateItem(row.product!.item_id ?? '', { image_url: up.image_url });
|
||||
if (res.result?.success === false) throw new Error(res.result.desc || '상품 수정 거부');
|
||||
setRowState(setRows, row.file, { state: 'done' });
|
||||
ok += 1;
|
||||
} catch (err) {
|
||||
setRowState(setRows, row.file, {
|
||||
state: 'failed',
|
||||
reason: err instanceof Error ? err.message : '업로드 실패',
|
||||
});
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] });
|
||||
setPhase('done');
|
||||
showToast(
|
||||
fail === 0 ? `${ok}개 상품 이미지가 연결되었습니다.` : `${ok}개 성공 · ${fail}개 실패`,
|
||||
fail === 0 ? 'success' : 'error',
|
||||
);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
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-3xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<Typography variant="h3">상품 이미지 일괄 업로드</Typography>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={phase === 'running'}
|
||||
className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground mt-3">
|
||||
파일명(확장자 제외)을 <Typography as="span" variant="small" className="text-[11px] font-bold text-foreground">상품코드</Typography>로
|
||||
매칭합니다. 예: <Typography as="span" variant="small" className="text-[11px] font-semibold text-foreground">ABC-001.jpg → 상품코드 ABC-001</Typography>
|
||||
</Typography>
|
||||
|
||||
{/* 파일 선택 존 */}
|
||||
<div
|
||||
onClick={() => phase !== 'running' && fileRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (phase !== 'running') handleFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`mt-4 rounded-md border-2 border-dashed border-border p-6 text-center transition-colors ${
|
||||
phase === 'running' ? 'opacity-50' : 'cursor-pointer hover:border-primary hover:bg-primary/5'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) handleFiles(e.target.files);
|
||||
e.target.value = ''; // 같은 파일 재선택 허용
|
||||
}}
|
||||
/>
|
||||
{matching ? (
|
||||
<Typography as="div" variant="small" className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" /> 상품 목록과 매칭 중…
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<ImagePlus size={20} className="mx-auto text-muted-foreground" />
|
||||
<Typography as="p" variant="small" className="mt-2 text-xs text-muted-foreground">
|
||||
이미지를 끌어다 놓거나 클릭해서 선택 (여러 장 가능)
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 옵션 + 매칭표 */}
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
<label className="mt-4 flex items-center gap-2 cursor-pointer w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwrite}
|
||||
disabled={phase === 'running'}
|
||||
onChange={(e) => setOverwrite(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-border accent-primary cursor-pointer"
|
||||
/>
|
||||
<Typography as="span" variant="small" className="text-xs">
|
||||
기존 이미지가 있는 상품도 덮어쓰기
|
||||
</Typography>
|
||||
</label>
|
||||
|
||||
<div className="mt-3 border border-border rounded-md overflow-hidden">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8.5rem] gap-x-3 px-3 py-2 bg-muted/40 border-b border-border">
|
||||
<Typography as="span" variant="mono" className="text-[10px] text-muted-foreground">파일명</Typography>
|
||||
<Typography as="span" variant="mono" className="text-[10px] text-muted-foreground">매칭 상품</Typography>
|
||||
<Typography as="span" variant="mono" className="text-[10px] text-muted-foreground text-right">상태</Typography>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto divide-y divide-border">
|
||||
{rows.map((r, i) => (
|
||||
<div key={`${r.file.name}-${i}`} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8.5rem] gap-x-3 px-3 py-1.5 items-center">
|
||||
<Typography as="span" variant="small" className="text-[11px] truncate" title={r.file.name}>
|
||||
{r.file.name}
|
||||
</Typography>
|
||||
<Typography as="span" variant="small" className="text-[11px] truncate text-muted-foreground" title={r.product?.name ?? ''}>
|
||||
{r.product ? r.product.name : '—'}
|
||||
</Typography>
|
||||
<span className="text-right">{renderStatus(r, overwrite)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 + 실행 */}
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
업로드 대상 {uploadTargets.length}건
|
||||
{skippedByImage.length > 0 && ` · 기존 이미지로 건너뜀 ${skippedByImage.length}건`}
|
||||
{rows.filter((r) => r.issue).length > 0 && ` · 제외 ${rows.filter((r) => r.issue).length}건`}
|
||||
</Typography>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={phase === 'running'}>
|
||||
{phase === 'done' ? '닫기' : '취소'}
|
||||
</Button>
|
||||
{phase !== 'done' && (
|
||||
<Button size="sm" onClick={handleRun} disabled={phase === 'running' || uploadTargets.length === 0}>
|
||||
{phase === 'running' ? (
|
||||
<>
|
||||
<Loader2 size={13} className="animate-spin" /> 업로드 중…
|
||||
</>
|
||||
) : (
|
||||
`업로드 시작 (${uploadTargets.length})`
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 파일 → 상품 매칭표. 코드 비교는 공백 제거 + 대소문자 무시(엑셀·수기 등록 코드 표기가 섞여 있어서).
|
||||
function buildRows(
|
||||
files: File[],
|
||||
products: Product[],
|
||||
myUserId: string | undefined,
|
||||
isSuperAdmin: boolean,
|
||||
): MatchRow[] {
|
||||
const byCode = new Map<string, Product[]>();
|
||||
for (const p of products) {
|
||||
const code = String(p.code ?? '').trim().toLowerCase();
|
||||
if (!code) continue;
|
||||
byCode.set(code, [...(byCode.get(code) ?? []), p]);
|
||||
}
|
||||
const seenBases = new Set<string>();
|
||||
return files.map((file) => {
|
||||
const base = file.name.replace(/\.[^.]+$/, '').trim();
|
||||
const key = base.toLowerCase();
|
||||
const matches = byCode.get(key) ?? [];
|
||||
const product = matches.length === 1 ? matches[0] : undefined;
|
||||
let issue: RowIssue | undefined;
|
||||
if (matches.length === 0) issue = 'unmatched';
|
||||
else if (matches.length > 1) issue = 'dup_code';
|
||||
else if (seenBases.has(key)) issue = 'dup_file';
|
||||
else if (!isSuperAdmin && product?.user_id !== myUserId) issue = 'forbidden';
|
||||
seenBases.add(key);
|
||||
const hasImage = !!product?.image_url && !product.image_url.includes(PLACEHOLDER_IMAGE_MARK);
|
||||
return { file, base, product, issue, hasImage, state: issue ? 'skip' : 'ready' } as MatchRow;
|
||||
});
|
||||
}
|
||||
|
||||
function isUploadable(row: MatchRow, overwrite: boolean): boolean {
|
||||
if (row.issue || !row.product) return false;
|
||||
if (!overwrite && row.hasImage) return false;
|
||||
return row.state === 'ready' || row.state === 'uploading' || row.state === 'failed';
|
||||
}
|
||||
|
||||
// 특정 파일 행의 상태만 갱신(불변 업데이트).
|
||||
function setRowState(
|
||||
setRows: React.Dispatch<React.SetStateAction<MatchRow[]>>,
|
||||
file: File,
|
||||
patch: Partial<Pick<MatchRow, 'state' | 'reason'>>,
|
||||
) {
|
||||
setRows((prev) => prev.map((r) => (r.file === file ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
const ISSUE_LABEL: Record<RowIssue, string> = {
|
||||
unmatched: '코드 미매칭',
|
||||
dup_code: '코드 중복',
|
||||
dup_file: '파일명 중복',
|
||||
forbidden: '권한 없음',
|
||||
};
|
||||
|
||||
function renderStatus(row: MatchRow, overwrite: boolean) {
|
||||
if (row.issue) {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-rose-500/10 text-rose-600 dark:text-rose-400">
|
||||
{ISSUE_LABEL[row.issue]}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (row.state === 'uploading') {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Loader2 size={10} className="animate-spin" /> 업로드 중
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (row.state === 'done') {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
|
||||
완료
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (row.state === 'failed') {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-rose-500/10 text-rose-600 dark:text-rose-400" title={row.reason}>
|
||||
실패
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (!overwrite && row.hasImage) {
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
이미지 있음 · 건너뜀
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{row.hasImage ? '덮어쓰기' : '업로드 대기'}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useAuthStore, canManage } from '@/stores/auth';
|
||||
import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||
import { useCompanySettings, useLabels, useHiddenFields, useVatMode } from '@/features/settings/useCompanySettings';
|
||||
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||
import { ItemSuppliersManager } from './ItemSuppliersManager';
|
||||
import { NewItemSuppliersPicker, type PickedSupplier } from './NewItemSuppliersPicker';
|
||||
@ -141,6 +141,7 @@ export function ProductFormSheet({
|
||||
|
||||
const label = useLabels(); // 회사 설정 용어
|
||||
const isHidden = useHiddenFields();
|
||||
const vatUnified = useVatMode() === 'unified_excluded'; // 부가세 전체 통일(VAT 별도) 회사
|
||||
// 숨김 필드는 DOM 에서 제거하지 않고 감추기만 한다 — 수정 시 기존 값이 그대로 유지·전송되도록.
|
||||
const hideCls = (key: string, base = 'space-y-1') => (isHidden(key) ? `${base} hidden` : base);
|
||||
const hideCls2 = (hidden: boolean, base: string) => (hidden ? `${base} hidden` : base);
|
||||
@ -178,7 +179,8 @@ export function ProductFormSheet({
|
||||
delivery_type: v.shippingType,
|
||||
moq: v.moq,
|
||||
lead_time: v.leadTime,
|
||||
vat_yn: v.vatYn,
|
||||
// 부가세를 관리하지 않는 회사(전체 통일 모드·숨김)는 값을 쓰지 않는다 — 스위치 기본값(포함)이 몰래 저장되는 것 방지.
|
||||
vat_yn: isHidden('vat_yn') ? undefined : v.vatYn,
|
||||
delivery_fee_yn: v.deliveryFeeYn,
|
||||
internet_lowest_price_yn: v.internetLowestPriceYn,
|
||||
internet_lowest_price: v.minPrice,
|
||||
@ -293,6 +295,12 @@ export function ProductFormSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전체 통일 회사는 가격 입력 기준을 폼에서 못 박는다 — 상품별 VAT 입력이 없어 달리 알 길이 없다. */}
|
||||
{vatUnified && (
|
||||
<Typography as="p" variant="caption" className="text-muted-foreground -mb-2">
|
||||
모든 가격은 VAT 별도(제외) 기준으로 입력합니다.
|
||||
</Typography>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Price */}
|
||||
<div className={hideCls('price')}>
|
||||
|
||||
@ -111,6 +111,14 @@ export function ProductTable({
|
||||
cellClassName: 'font-mono font-bold text-foreground',
|
||||
cell: (prod) => `₩${(prod.price || 0).toLocaleString()}`,
|
||||
},
|
||||
{
|
||||
field: 'purchase_price',
|
||||
header: label('item.purchase_price'),
|
||||
align: 'right',
|
||||
cellClassName: 'font-mono font-bold text-foreground',
|
||||
cell: (prod) =>
|
||||
prod.purchase_price != null ? `₩${Number(prod.purchase_price).toLocaleString()}` : '-',
|
||||
},
|
||||
{
|
||||
field: 'internet_lowest_price',
|
||||
header: label('item.internet_lowest_price'),
|
||||
|
||||
@ -6,7 +6,7 @@ import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
import { useListCards } from '@/api/generated/card/card';
|
||||
import { mapCardData } from '@/features/cards/types';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { useLabels, useVatMode } from '@/features/settings/useCompanySettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -117,6 +117,7 @@ export function QuotationCreateModal({
|
||||
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const vatUnified = useVatMode() === 'unified_excluded'; // 부가세 전체 통일 — 목표가 입력 기준 안내
|
||||
const supplyTypeBySupplier = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
||||
@ -556,6 +557,7 @@ export function QuotationCreateModal({
|
||||
{autoTarget != null
|
||||
? '위에서 선택한 후보값이 목표가(1순위)로 입력됩니다. 수정 가능.'
|
||||
: '자동 산출값이 없어 직접 입력이 필요합니다.'}
|
||||
{vatUnified && ' 금액은 VAT 별도(제외) 기준입니다.'}
|
||||
</Typography>
|
||||
{!targetReady && (
|
||||
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
|
||||
|
||||
@ -8,12 +8,12 @@ import SlateRenderer from '@/components/SlateRenderer';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||
import { maskPrices } from '@/lib/utils';
|
||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||
import { renderEmphasis } from '@/lib/emphasis';
|
||||
import { renderCardScriptPreview } from '@/features/cards/editor';
|
||||
|
||||
// 협상로그 JSON 다운로드(IMK #9). 가격·비율 숫자는 maskPrices 로 가려 내보낸다(화면 표기와 동일 규칙).
|
||||
// 협상로그 JSON 다운로드(IMK #9). 가격·인하율은 실값 그대로 내보낸다 — 이 탭을 열 수 있는 사람이
|
||||
// 이미 공개 대상(해당 견적 담당자∪최고관리자, 조회 게이팅)뿐이라 마스킹이 하던 차단 역할이 없다.
|
||||
// target_price 등 숫자 필드는 아예 제외 — 양식은 대화 흐름(순번/발화자/스텝/멘트/카드사용) 중심.
|
||||
function exportChatJson(
|
||||
session: SessionData | undefined,
|
||||
@ -37,7 +37,7 @@ function exportChatJson(
|
||||
seq: m.index,
|
||||
sender: m.sender === ChatSender.BOT ? 'BOT' : 'PARTNER',
|
||||
step: m.step ?? null,
|
||||
script: maskPrices(String(m.script ?? '')),
|
||||
script: String(m.script ?? ''),
|
||||
card: card
|
||||
? {
|
||||
number: card.number ?? null,
|
||||
@ -153,7 +153,7 @@ export function ChatTab({
|
||||
type="button"
|
||||
onClick={() => exportChatJson(currentSession, currentSupplierName, currentProduct?.name, chatMessages, serverCards)}
|
||||
disabled={chatMessages.length === 0}
|
||||
title="협상로그 JSON 내보내기 (금액 숫자는 가려서 저장)"
|
||||
title="협상로그 JSON 내보내기"
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-border text-[10px] font-mono text-muted-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Download size={11} />
|
||||
@ -243,7 +243,7 @@ function BotBubble({
|
||||
)}
|
||||
{m.script && !usedCard && (
|
||||
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
|
||||
{renderEmphasis(maskPrices(m.script))}
|
||||
{renderEmphasis(m.script)}
|
||||
</Typography>
|
||||
)}
|
||||
<UsedCardBox
|
||||
@ -259,7 +259,7 @@ function BotBubble({
|
||||
);
|
||||
}
|
||||
|
||||
// 협력사(우측) 말풍선. 협력사 입력값을 보여주되, 채팅 '내용'에 제시 금액이 노출되지 않도록 maskPrices 로 가린다.
|
||||
// 협력사(우측) 말풍선. 제시 금액은 실값 그대로 — 열람 가능자가 곧 공개 대상(조회 게이팅)이다.
|
||||
function PartnerBubble({
|
||||
message,
|
||||
currentSupplierName,
|
||||
@ -288,7 +288,7 @@ function PartnerBubble({
|
||||
)}
|
||||
{m.script && (
|
||||
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
|
||||
{maskPrices(m.script)}
|
||||
{m.script}
|
||||
</Typography>
|
||||
)}
|
||||
<UsedCardBox
|
||||
@ -387,7 +387,7 @@ function UsedCardBox({
|
||||
</div>
|
||||
) : usedCard.script ? (
|
||||
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
|
||||
{renderCardScriptPreview(maskPrices(usedCard.script))}
|
||||
{renderCardScriptPreview(usedCard.script)}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy, ClipboardList, X } from 'lucide-react';
|
||||
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy, ClipboardList, X, Loader2 } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -141,7 +141,7 @@ export function SessionsStatusTab({
|
||||
}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Mail size={14} />
|
||||
{sendingAll ? <Loader2 size={14} className="animate-spin" /> : <Mail size={14} />}
|
||||
<span>{sendingAll ? '발송 중…' : `초청 메일 발송${unsentCount > 0 ? ` (${unsentCount})` : ''}`}</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -296,10 +296,10 @@ export function SessionsStatusTab({
|
||||
<TooltipTrigger render={
|
||||
<button
|
||||
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
|
||||
disabled={!canNotify || sendingId === sess.session_id}
|
||||
disabled={!canNotify || sendingAll || sendingId === sess.session_id}
|
||||
className="p-1 rounded text-primary hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send size={12} />
|
||||
{sendingId === sess.session_id ? <Loader2 size={12} className="animate-spin" /> : <Send size={12} />}
|
||||
</button>
|
||||
} />
|
||||
<TooltipContent>
|
||||
@ -429,14 +429,20 @@ export function SessionsStatusTab({
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
|
||||
disabled={!canNotify || sendingId === sess.session_id}
|
||||
disabled={!canNotify || sendingAll || sendingId === sess.session_id}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
sess.email_sent_at ? 'text-success' : 'text-primary',
|
||||
)}
|
||||
>
|
||||
{sess.email_sent_at ? <MailCheck size={12} /> : <Mail size={12} />}
|
||||
{sess.email_sent_at ? '메일 재발송' : '메일 발송'}
|
||||
{sendingId === sess.session_id ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : sess.email_sent_at ? (
|
||||
<MailCheck size={12} />
|
||||
) : (
|
||||
<Mail size={12} />
|
||||
)}
|
||||
{sendingId === sess.session_id ? '발송 중…' : sess.email_sent_at ? '메일 재발송' : '메일 발송'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,7 +2,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';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { useLabels, useVatMode } from '@/features/settings/useCompanySettings';
|
||||
|
||||
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
|
||||
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
|
||||
@ -39,6 +39,8 @@ export function TargetPriceModal({
|
||||
}: TargetPriceModalProps) {
|
||||
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
|
||||
const label = useLabels(); // 회사 설정 용어
|
||||
// 부가세 전체 통일 회사는 상품 vat_yn 잔존값과 무관하게 'VAT별도' 고정.
|
||||
const vatUnified = useVatMode() === 'unified_excluded';
|
||||
const CANDIDATE_SUB = candidateSub(`${label('target_margin')}`);
|
||||
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
|
||||
const candidates = bd?.candidates ?? [];
|
||||
@ -64,7 +66,7 @@ export function TargetPriceModal({
|
||||
견적번호: {qtNumber} · 상품: {itemName}
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] font-semibold text-rose-500 mt-1">
|
||||
배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {vatYn ? 'VAT포함' : 'VAT별도'}
|
||||
배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {!vatUnified && vatYn ? 'VAT포함' : 'VAT별도'}
|
||||
</Typography>
|
||||
|
||||
{isLoading || !bd ? (
|
||||
|
||||
@ -180,7 +180,9 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, se
|
||||
},
|
||||
{
|
||||
header: '생성일',
|
||||
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
|
||||
// 10컬럼 표라 좁은 데스크톱(xl~2xl)에선 부차 정보인 생성일을 접어 가로 스크롤을 줄인다.
|
||||
headClassName: 'hidden 2xl:table-cell',
|
||||
cellClassName: 'hidden 2xl:table-cell font-mono text-muted-foreground whitespace-nowrap',
|
||||
cell: (est) => (
|
||||
<Typography as="span" variant="small" className="text-xs text-inherit">{est.createdDate ?? '-'}</Typography>
|
||||
),
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type ElementType } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Palette, Tags, ListPlus, MessageSquareText, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import ImageDropzone from '@/components/ImageDropzone';
|
||||
import { uploadItemImage } from '@/api/generated/item/item';
|
||||
import { previewInviteEmail } from '@/api/generated/company-settings/company-settings';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
@ -19,6 +21,8 @@ import {
|
||||
DEFAULT_GUIDE_NOTICES,
|
||||
NEGO_BASELINE_OPTIONS,
|
||||
DEFAULT_NEGO_BASELINE_FIELD,
|
||||
VAT_MODE_OPTIONS,
|
||||
DEFAULT_VAT_MODE,
|
||||
type CompanySettings,
|
||||
type CustomFieldDef,
|
||||
type CustomFieldType,
|
||||
@ -48,6 +52,12 @@ const SETTINGS_TAB_ICON: Record<SettingsTab, ElementType> = {
|
||||
fields: ListPlus,
|
||||
};
|
||||
|
||||
// branding 의 문자열 서브키(helpdesk 만 배열이라 제외). email_* 는 협상 초청 메일 커스텀.
|
||||
type BrandingTextKey =
|
||||
| 'service_name'
|
||||
| 'logo_url'
|
||||
| 'email_greeting';
|
||||
|
||||
export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly SettingsTab[] }) {
|
||||
const { settings, isLoading, save } = useCompanySettings();
|
||||
|
||||
@ -141,7 +151,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
|
||||
return res.image_url;
|
||||
};
|
||||
|
||||
const setBranding = (key: 'service_name' | 'logo_url' | 'primary_color' | 'email_header', value: string) =>
|
||||
const setBranding = (key: BrandingTextKey, value: string) =>
|
||||
setDraft((d) => ({ ...d, branding: { ...d.branding, [key]: value } }));
|
||||
const setHelpdesk = (lines: string[]) =>
|
||||
setDraft((d) => ({ ...d, branding: { ...d.branding, helpdesk: lines } }));
|
||||
@ -197,7 +207,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
|
||||
<TabsContent value="branding" className="space-y-4">
|
||||
<SectionCard
|
||||
title="서비스 브랜딩"
|
||||
desc="사이드바·타이틀에 노출되는 서비스명과 로고입니다. 비워두면 기본 브랜드(NegoData)가 사용됩니다."
|
||||
desc="회사 CI 입니다. 사이드바·타이틀은 물론 공급사 포털(로그인·버튼)과 협상 초청 메일에도 그대로 적용됩니다. 비워두면 기본 브랜드(NegoData)가 사용됩니다."
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="서비스명" hint="예: iMarketKorea 구매협상 콘솔">
|
||||
@ -229,16 +239,32 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
|
||||
{draft.branding?.logo_url ? (
|
||||
<img src={draft.branding.logo_url} alt="로고 미리보기" className="h-4 max-w-24 object-contain" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-4 rounded-[5px]"
|
||||
style={{ backgroundColor: draft.branding?.primary_color || 'var(--primary)' }}
|
||||
/>
|
||||
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
|
||||
)}
|
||||
{draft.branding?.service_name || 'NegoData'}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="협상 초청 메일"
|
||||
desc="공급사 담당자에게 발송되는 협상 초청 메일입니다. 로고·회사명·색은 위 서비스 브랜딩(CI)을 그대로 따르고, 메일에서 바꿀 수 있는 건 인사 문구뿐입니다. 레이아웃은 고정이라 어떤 메일 앱에서도 깨지지 않습니다."
|
||||
>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_540px] gap-6">
|
||||
<div className="space-y-4">
|
||||
<Field label="인사 문구" hint="'OO 담당자님,' 다음 줄에 들어갑니다.">
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full p-2 bg-background border border-border rounded text-xs resize-none"
|
||||
value={draft.branding?.email_greeting ?? ''}
|
||||
onChange={(e) => setBranding('email_greeting', e.target.value)}
|
||||
placeholder="아래 견적 건의 협상에 참여해 주세요. (기본값)"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<InviteEmailPreview branding={draft.branding} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---- 공급사 포털 안내 ---- */}
|
||||
@ -265,7 +291,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
|
||||
<LineListEditor
|
||||
lines={draft.branding?.helpdesk ?? []}
|
||||
onChange={setHelpdesk}
|
||||
placeholder="김건우P 02-3708-5832 kw086.kim@imarketkorea.com"
|
||||
placeholder="홍길동P 02-0000-0000 helpdesk@example.com"
|
||||
addLabel="연락처 추가"
|
||||
emptyHint="등록된 연락처가 없습니다. 공급사 포털에 연락처 영역이 표시되지 않습니다."
|
||||
/>
|
||||
@ -331,7 +357,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
|
||||
<TabsContent value="fields" className="space-y-4">
|
||||
<SectionCard
|
||||
title="협상 기준가"
|
||||
desc="협상 중 '기존 OO 대비 N% 인하' 를 계산하는 기준 가격입니다. 우리 회사가 공급사에 실제로 지불 중인 단가를 고르십시오."
|
||||
desc="협상 중 '기존 공급가 대비 N% 인하' 를 계산하는 기준 가격입니다. 우리 회사가 공급사에 실제로 지불 중인 단가를 고르십시오. 공급사 화면 표기는 어느 필드를 고르든 '공급가'로 고정됩니다."
|
||||
>
|
||||
<NegoBaselinePicker
|
||||
value={draft.features?.nego_baseline_field ?? DEFAULT_NEGO_BASELINE_FIELD}
|
||||
@ -343,6 +369,43 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="부가세(VAT) 관리"
|
||||
desc="가격 정보의 부가세 기준입니다. 전체 통일을 선택하면 상품별 부가세 입력이 폼·엑셀 양식에서 사라지고, 공급사 협상 화면 가격에 'VAT 별도'가 항상 표기됩니다. DB 컬럼과 기존 값은 그대로 남습니다."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{VAT_MODE_OPTIONS.map((opt) => {
|
||||
const selected = (draft.features?.vat_mode ?? DEFAULT_VAT_MODE) === opt.value;
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-start gap-2.5 p-3 rounded-md border cursor-pointer ${
|
||||
selected ? 'border-primary bg-primary/5' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="vat_mode"
|
||||
className="mt-1"
|
||||
checked={selected}
|
||||
onChange={() =>
|
||||
setDraft((d) => ({ ...d, features: { ...d.features, vat_mode: opt.value } }))
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 space-y-1">
|
||||
<Typography as="span" variant="small" className="block font-semibold">
|
||||
{opt.label}
|
||||
</Typography>
|
||||
<Typography as="span" variant="caption" className="block text-muted-foreground">
|
||||
{opt.desc}
|
||||
</Typography>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="상품 필드 숨김"
|
||||
desc="체크한 항목은 상품 목록·등록 폼·엑셀 양식에서 감춰집니다. DB 컬럼과 기존 값은 그대로 남습니다."
|
||||
@ -350,12 +413,23 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
|
||||
{HIDEABLE_ITEM_FIELDS.map((f) => {
|
||||
const checked = (draft.hidden_fields ?? []).includes(f.key);
|
||||
// 협상 기준가로 지정된 필드는 숨김 금지(강제) — 입력 화면이 사라지면 신규 상품의
|
||||
// 기준값이 NULL 로 쌓여 인하율 멘트가 통째로 빠진다. 이미 숨겨진 상태면 해제만 허용.
|
||||
const baselineLocked =
|
||||
f.key === (draft.features?.nego_baseline_field ?? DEFAULT_NEGO_BASELINE_FIELD) &&
|
||||
!checked;
|
||||
return (
|
||||
<label key={f.key} className="flex items-start gap-2 py-1 cursor-pointer">
|
||||
<label
|
||||
key={f.key}
|
||||
className={`flex items-start gap-2 py-1 ${
|
||||
baselineLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={checked}
|
||||
disabled={baselineLocked}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => {
|
||||
const cur = new Set(d.hidden_fields ?? []);
|
||||
@ -372,6 +446,11 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
|
||||
<Typography as="span" variant="caption" className="block text-muted-foreground">
|
||||
{f.where}
|
||||
</Typography>
|
||||
{baselineLocked && (
|
||||
<Typography as="span" variant="caption" className="block text-amber-600">
|
||||
⚠ 협상 기준가로 지정된 필드는 숨길 수 없습니다
|
||||
</Typography>
|
||||
)}
|
||||
{f.calcNote && (
|
||||
<Typography as="span" variant="caption" className="block text-amber-600">
|
||||
⚠ {f.calcNote}
|
||||
@ -484,9 +563,6 @@ function NegoBaselinePicker({
|
||||
<Typography as="span" variant="caption" className="block text-muted-foreground">
|
||||
{opt.desc}
|
||||
</Typography>
|
||||
<Typography as="span" variant="caption" className="block text-muted-foreground">
|
||||
협상 멘트 — 기존 {phrase} 대비 약 3.2% 인하된 금액입니다.
|
||||
</Typography>
|
||||
{selected && hiddenFields.includes(opt.value) && (
|
||||
<Typography as="span" variant="caption" className="block text-amber-600">
|
||||
⚠ 이 필드를 아래에서 숨김 처리했습니다. 값을 입력할 화면이 없어 신규 상품은 기준가가 비어
|
||||
@ -497,6 +573,10 @@ function NegoBaselinePicker({
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<Typography variant="caption" className="block text-muted-foreground">
|
||||
협상 멘트(공급사 화면) — 기존 공급가 대비 약 3.2% 인하된 금액입니다. 여기서 고른 필드는 계산에 쓰는
|
||||
값만 정하고, 공급사에게 보이는 호칭은 항상 ‘공급가’입니다.
|
||||
</Typography>
|
||||
<Typography variant="caption" className="block text-amber-600">
|
||||
⚠ 이미 협상을 진행한 회사가 이 값을 바꾸면 가격 수용률 기준이 달라져, 기존 학습분과 이후 데이터가
|
||||
섞입니다. 협상 시작 전에 정하십시오. (진행 중인 협상은 시작 시점 기준을 그대로 유지합니다)
|
||||
@ -588,6 +668,60 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
|
||||
);
|
||||
}
|
||||
|
||||
// 초청 메일 미리보기 — 실제 발송과 같은 백엔드 템플릿을 샘플 견적으로 렌더해 iframe 에 띄운다.
|
||||
// 입력 중 매 타자마다 서버를 부르지 않도록 디바운스하고, 갱신 사이엔 직전 렌더를 유지해 깜빡임을 없앤다.
|
||||
function InviteEmailPreview({ branding }: { branding?: CompanySettings['branding'] }) {
|
||||
// 메일 헤더는 회사 CI(로고·서비스명)를 그대로 쓰므로 CI 편집값을 같이 보낸다(미리보기 = 실발송). 색은 솔루션 고정.
|
||||
const emailBranding = JSON.stringify({
|
||||
service_name: branding?.service_name ?? '',
|
||||
logo_url: branding?.logo_url ?? '',
|
||||
email_greeting: branding?.email_greeting ?? '',
|
||||
});
|
||||
const debounced = useDebounced(emailBranding, 400);
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ['invite-email-preview', debounced],
|
||||
queryFn: () => previewInviteEmail({ branding: JSON.parse(debounced) }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Typography variant="muted" className="text-[10px] block">
|
||||
미리보기 — 실제 발송 템플릿을 샘플 견적으로 렌더한 것입니다.
|
||||
</Typography>
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<div className="border-b border-border bg-background px-3 py-2">
|
||||
<Typography variant="caption" className="block text-muted-foreground">제목</Typography>
|
||||
<Typography variant="small" className="block font-semibold truncate">
|
||||
{data?.subject || '미리보기를 불러오는 중...'}
|
||||
</Typography>
|
||||
</div>
|
||||
{isError ? (
|
||||
<Typography variant="muted" className="block p-6 text-center">
|
||||
미리보기를 불러오지 못했습니다. 잠시 후 다시 시도하십시오.
|
||||
</Typography>
|
||||
) : (
|
||||
<iframe
|
||||
title="협상 초청 메일 미리보기"
|
||||
srcDoc={data?.html ?? ''}
|
||||
sandbox=""
|
||||
className="w-full h-[600px] bg-[#f4f5f7]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 값이 delayMs 동안 잠잠해진 뒤에만 반영되는 디바운스 훅(미리보기 서버호출 절약용).
|
||||
function useDebounced(value: string, delayMs: number): string {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
// 커스텀필드 정의 편집 — 표시명을 입력하면 key 를 자동 제안하되 직접 수정도 가능.
|
||||
function CustomFieldsEditor({
|
||||
title,
|
||||
|
||||
@ -5,6 +5,8 @@ export type CustomFieldType = 'text' | 'number' | 'boolean' | 'select';
|
||||
|
||||
export type NegoBaselineField = 'price' | 'purchase_price';
|
||||
|
||||
export type VatMode = 'per_item' | 'unified_excluded';
|
||||
|
||||
export type CustomFieldDef = {
|
||||
key: string; // custom JSONB 의 키 (영문 snake_case)
|
||||
label: string; // 화면 표시명
|
||||
@ -16,8 +18,7 @@ export type CompanySettings = {
|
||||
branding?: {
|
||||
service_name?: string; // 사이드바/타이틀 서비스명 (기본 NegoData)
|
||||
logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트
|
||||
primary_color?: string; // 브랜드 색 (hex)
|
||||
email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA)
|
||||
email_greeting?: string; // 초청 메일 인사 문구 ('OO 담당자님,' 다음 줄). 메일 헤더는 CI(logo_url·service_name)를 따르고 색은 솔루션 고정
|
||||
helpdesk?: string[]; // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 공급사 포털 3곳(로그인·메뉴·안내팝업)이 그대로 출력
|
||||
};
|
||||
labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값)
|
||||
@ -25,6 +26,9 @@ export type CompanySettings = {
|
||||
// 협상 기준가로 쓸 상품 가격 컬럼. 인하율 멘트의 분모이자 RL 가격 수용률의 기준가다.
|
||||
// 미설정이면 공급가(price) — 단 공급가를 숨긴 회사는 매입가로 폴백(협상 엔진).
|
||||
nego_baseline_field?: NegoBaselineField;
|
||||
// 부가세 관리 방식. 미설정=상품별(per_item). 전체 통일(unified_excluded)이면 상품 VAT 입력을
|
||||
// 숨기고(폼·엑셀) 공급사 협상 화면 가격에 'VAT 별도'를 고정 표기한다(협상 백엔드 chat_service).
|
||||
vat_mode?: VatMode;
|
||||
[key: string]: unknown; // 그 밖의 회사별 동작 플래그
|
||||
};
|
||||
guide_notices?: string[]; // 공급사 포털 협상 유의사항 항목 — 한 줄 = 안내 한 항목. 비면 기본 문구
|
||||
@ -45,7 +49,7 @@ export type HideableFieldEntry = {
|
||||
|
||||
export const HIDEABLE_ITEM_FIELDS: HideableFieldEntry[] = [
|
||||
{ key: 'made_in', label: '원산지(제조 국가)', where: '상품 등록, 엑셀 양식' },
|
||||
{ key: 'vat_yn', label: '부가세포함', where: '상품 등록, 엑셀 양식' },
|
||||
// vat_yn 은 숨김 목록에서 뺐다 — 부가세는 '부가세(VAT) 관리' 모드(features.vat_mode)로 관리한다.
|
||||
{ key: 'delivery_fee_yn', label: '배송비포함', where: '상품 등록, 엑셀 양식' },
|
||||
{ key: 'spec', label: '규격', where: '상품 등록, 엑셀 양식' },
|
||||
{ key: 'manufacturer', label: '제조사', where: '상품 등록, 엑셀 양식' },
|
||||
@ -69,7 +73,6 @@ export const HIDEABLE_ITEM_FIELDS: HideableFieldEntry[] = [
|
||||
key: 'price',
|
||||
label: '상품 단가(공급가)',
|
||||
where: '상품 목록·등록, 엑셀 양식',
|
||||
calcNote: '협상 기준가로 고른 경우 숨기지 말 것 — 인하율 멘트의 기준값이 비게 된다',
|
||||
},
|
||||
];
|
||||
// internet_lowest_price 는 숨김 대상에서 뺀다 — 신규 견적의 유일한 목표가 후보라
|
||||
@ -89,7 +92,8 @@ export const LABEL_CATALOG: LabelCatalogEntry[] = [
|
||||
{ key: 'item.code', base: '상품코드', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
|
||||
{ key: 'item.model_name', base: '모델번호', where: '상품 등록, 엑셀 양식', group: '상품' },
|
||||
{ key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계', group: '상품' },
|
||||
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
|
||||
// 관리자 화면 전용 — 공급사 화면(포털·협상 멘트)의 기준가 표기는 '공급가'로 고정이라 이 용어를 타지 않는다.
|
||||
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식 (관리자 화면 전용)', group: '상품' },
|
||||
{ key: 'item.purchase_price', base: '매입가', where: '상품 등록, 엑셀 양식', group: '상품' },
|
||||
{ key: 'item.selling_price', base: '판매가', where: '상품 등록, 엑셀 양식', group: '상품' },
|
||||
{ key: 'item.internet_lowest_price', base: '인터넷 최저가', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
|
||||
@ -155,6 +159,23 @@ export const NEGO_BASELINE_OPTIONS: {
|
||||
|
||||
export const DEFAULT_NEGO_BASELINE_FIELD: NegoBaselineField = 'price';
|
||||
|
||||
export const DEFAULT_VAT_MODE: VatMode = 'per_item';
|
||||
|
||||
// 부가세 관리 모드 선택지. 전체 통일은 'VAT 별도(제외)' 고정만 둔다 — 포함으로 통일하려는
|
||||
// 회사가 나오면 값을 추가한다(지금 만들면 안 쓰는 분기만 는다).
|
||||
export const VAT_MODE_OPTIONS: { value: VatMode; label: string; desc: string }[] = [
|
||||
{
|
||||
value: 'per_item',
|
||||
label: '상품별 관리',
|
||||
desc: '상품마다 부가세 포함 여부를 입력합니다. 공급사 협상 화면에는 상품에 입력된 값대로 VAT 포함/별도가 표기됩니다.',
|
||||
},
|
||||
{
|
||||
value: 'unified_excluded',
|
||||
label: '전체 통일 — VAT 별도(제외)',
|
||||
desc: '모든 가격을 VAT 제외 기준으로 통일합니다. 상품 등록·엑셀 양식에서 부가세 입력이 사라지고, 공급사 협상 화면 가격에는 항상 "VAT 별도"가 표기됩니다.',
|
||||
},
|
||||
];
|
||||
|
||||
// 공급사 포털 협상 유의사항 기본 문구. 설정이 비어 있을 때 포털이 쓰는 값과 같아야 한다
|
||||
// (포털 사본: frontend/src/features/chat/components/popup/GuideContent.tsx).
|
||||
// VAT·배송비 조건은 상품마다 달라 기본 문구에서 뺐다 — 필요한 회사가 항목으로 직접 넣는다.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGetSettings, updateSettings, getGetSettingsQueryKey } from '@/api/generated/company-settings/company-settings';
|
||||
import { LABEL_DEFAULTS, type CompanySettings } from './catalog';
|
||||
import { LABEL_DEFAULTS, DEFAULT_VAT_MODE, type CompanySettings, type VatMode } from './catalog';
|
||||
|
||||
// 회사 커스터마이징 설정 조회 + 저장.
|
||||
// 조회는 전 유저(브랜딩/라벨 렌더용), 저장은 백엔드가 OWNER 로 게이트한다.
|
||||
@ -26,18 +26,26 @@ export function useLabels() {
|
||||
}
|
||||
|
||||
// 숨김필드 헬퍼. isHidden('made_in') → 이 회사에서 감출 필드인지.
|
||||
// 부가세 전체 통일(features.vat_mode) 회사는 상품별 VAT 입력이 무의미하므로 vat_yn 을 숨김으로 친다 —
|
||||
// 상품 폼·엑셀 양식·데이터 내보내기가 이 헬퍼 하나를 읽어 함께 사라진다.
|
||||
export function useHiddenFields() {
|
||||
const { settings } = useCompanySettings();
|
||||
const hidden = settings.hidden_fields ?? [];
|
||||
return (key: string): boolean => hidden.includes(key);
|
||||
const vatUnified = settings.features?.vat_mode === 'unified_excluded';
|
||||
return (key: string): boolean => hidden.includes(key) || (vatUnified && key === 'vat_yn');
|
||||
}
|
||||
|
||||
// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData).
|
||||
// 부가세 관리 모드 헬퍼. 미설정이면 상품별(per_item).
|
||||
export function useVatMode(): VatMode {
|
||||
const { settings } = useCompanySettings();
|
||||
return settings.features?.vat_mode ?? DEFAULT_VAT_MODE;
|
||||
}
|
||||
|
||||
// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData). 색은 회사별 커스텀 없이 솔루션 고정.
|
||||
export function useBranding() {
|
||||
const { settings } = useCompanySettings();
|
||||
return {
|
||||
serviceName: settings.branding?.service_name || 'NegoData',
|
||||
logoUrl: settings.branding?.logo_url || null,
|
||||
primaryColor: settings.branding?.primary_color || null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -23,8 +23,9 @@ export function StatisticsView({ data }: { data: StatData }) {
|
||||
const markupTrend = fillMonths(data.markupTrend, (month) => ({ month, rate: 0 }));
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 임팩트 요약 KPI */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{/* 임팩트 요약 KPI — 6열은 초광폭(2xl)에서만. xl 구간은 사이드바 빼면 타일이 ~170px 로 눌려
|
||||
라벨·증감칩이 여러 줄로 깨진다 → 3열 유지. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
|
||||
<StatTile
|
||||
label="총 절감액 (목표가 대비)"
|
||||
value={wonCompact(k.totalSavings)}
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import { TablePagination } from '@/components/ui/table-pagination';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerClose,
|
||||
} from '@/components/ui/drawer';
|
||||
import { Plus, Upload, TrendingDown, Trash2 } from 'lucide-react';
|
||||
import { showToast } from '@/lib/notify';
|
||||
|
||||
/**
|
||||
* 디자인시스템 쇼케이스 (dev 전용).
|
||||
* 라우터에서 import.meta.env.DEV일 때만 등록되므로 프로덕션 빌드에는 포함되지 않는다.
|
||||
* 토큰/프리미티브의 단일 출처를 눈으로 확인하고 맞추는 곳.
|
||||
*/
|
||||
export default function DevDesignPage() {
|
||||
const [switchOn, setSwitchOn] = useState(true);
|
||||
const [switchOff, setSwitchOff] = useState(false);
|
||||
const [demoPage, setDemoPage] = useState(2);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="mx-auto max-w-4xl px-6 py-12 space-y-12">
|
||||
<header className="space-y-1">
|
||||
<Typography variant="mono">네고시움 디자인시스템 · 개발 전용</Typography>
|
||||
<Typography variant="h1">디자인시스템</Typography>
|
||||
<Typography variant="muted">
|
||||
텍스트·색·컴포넌트의 단일 출처. 페이지에서 직접 클래스 박지 말고 여기 토큰을 쓴다.
|
||||
</Typography>
|
||||
</header>
|
||||
|
||||
<Section title="타이포그래피">
|
||||
<div className="space-y-2">
|
||||
<Typography variant="h1">H1 · 페이지 타이틀</Typography>
|
||||
<Typography variant="h2">H2 · 섹션 타이틀</Typography>
|
||||
<Typography variant="h3">H3 · 카드/모달 타이틀</Typography>
|
||||
<Typography variant="h4">H4 · 서브 타이틀</Typography>
|
||||
<Typography variant="body">Body · 기본 본문 텍스트입니다.</Typography>
|
||||
<Typography variant="small">Small · 작은 본문 텍스트입니다.</Typography>
|
||||
<Typography variant="muted">Muted · 보조 설명 텍스트입니다.</Typography>
|
||||
<Typography variant="label">Label · 폼 라벨</Typography>
|
||||
<br />
|
||||
<Typography variant="mono">Mono · 콘솔/메타 캡션</Typography>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="색상 토큰">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Swatch name="background" className="bg-background border border-border" />
|
||||
<Swatch name="card" className="bg-card border border-border" />
|
||||
<Swatch name="muted" className="bg-muted" />
|
||||
<Swatch name="primary" className="bg-primary" />
|
||||
<Swatch name="secondary" className="bg-secondary" />
|
||||
<Swatch name="destructive" className="bg-destructive" />
|
||||
<Swatch name="border" className="bg-border" />
|
||||
<Swatch name="foreground" className="bg-foreground" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="버튼">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(['default', 'outline', 'secondary', 'ghost', 'destructive', 'link'] as const).map((v) => (
|
||||
<Button key={v} variant={v}>{v}</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-3">
|
||||
{(['xs', 'sm', 'default', 'lg'] as const).map((s) => (
|
||||
<Button key={s} size={s}>{s}</Button>
|
||||
))}
|
||||
<Button disabled>disabled</Button>
|
||||
</div>
|
||||
{/* 실제 사용 케이스: 아이콘+라벨, 카운트 배지, 위험 액션 */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-3">
|
||||
<Button><Plus />신규 등록</Button>
|
||||
<Button variant="outline"><Upload />엑셀 업로드</Button>
|
||||
<Button variant="outline">
|
||||
<TrendingDown />최저가 업데이트
|
||||
<Badge variant="destructive">3</Badge>
|
||||
</Button>
|
||||
<Button variant="destructive"><Trash2 />삭제</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="배지">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'] as const).map((v) => (
|
||||
<Badge key={v} variant={v}>{v}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="입력창">
|
||||
<div className="max-w-sm space-y-3">
|
||||
<Input placeholder="기본 입력" />
|
||||
<Input placeholder="비활성" disabled />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="스위치">
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2">
|
||||
<Switch checked={switchOn} onCheckedChange={setSwitchOn} />
|
||||
<Typography variant="small">{switchOn ? '켜짐' : '꺼짐'}</Typography>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Switch checked={switchOff} onCheckedChange={setSwitchOff} />
|
||||
<Typography variant="small">토글</Typography>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 opacity-60">
|
||||
<Switch checked disabled />
|
||||
<Typography variant="small">disabled</Typography>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="카드">
|
||||
<Card className="max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>카드 타이틀</CardTitle>
|
||||
<CardDescription>shadcn Card 프리미티브. 패딩·보더·그림자 단일 출처.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant="small">카드 본문 영역입니다.</Typography>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end gap-2">
|
||||
<Button variant="outline" size="sm">취소</Button>
|
||||
<Button size="sm">확인</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Section title="탭">
|
||||
<Tabs defaultValue="overview" className="max-w-md">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">개요</TabsTrigger>
|
||||
<TabsTrigger value="detail">상세</TabsTrigger>
|
||||
<TabsTrigger value="log">로그</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="pt-3">
|
||||
<Typography variant="small">개요 탭 내용.</Typography>
|
||||
</TabsContent>
|
||||
<TabsContent value="detail" className="pt-3">
|
||||
<Typography variant="small">상세 탭 내용.</Typography>
|
||||
</TabsContent>
|
||||
<TabsContent value="log" className="pt-3">
|
||||
<Typography variant="small">로그 탭 내용.</Typography>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Section>
|
||||
|
||||
<Section title="표">
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>상품명</TableHead>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[
|
||||
{ name: '배터리 팩', code: 'PROD-BAT-900', price: 1000000 },
|
||||
{ name: '인버터 블록', code: 'PROD-INV-11', price: 3200000 },
|
||||
].map((r) => (
|
||||
<TableRow key={r.code}>
|
||||
<TableCell className="font-medium">{r.name}</TableCell>
|
||||
<TableCell className="font-mono text-muted-foreground">{r.code}</TableCell>
|
||||
<TableCell className="text-right font-mono">₩{r.price.toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="페이지네이션 푸터">
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="p-4 text-center">
|
||||
<Typography variant="small" className="text-muted-foreground">표 하단에 들어가는 목록 페이지네이션</Typography>
|
||||
</div>
|
||||
<TablePagination
|
||||
page={demoPage}
|
||||
totalPages={12}
|
||||
totalCount={58}
|
||||
pageSize={5}
|
||||
onPageChange={setDemoPage}
|
||||
label="검색 결과"
|
||||
unit="건"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="모달">
|
||||
<Dialog>
|
||||
<DialogTrigger render={<Button variant="outline">모달 열기</Button>} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>모달 타이틀</DialogTitle>
|
||||
<DialogDescription>
|
||||
base-ui Dialog 프리미티브. 페이지마다 fixed inset-0 직접 짜지 말 것.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline">취소</Button>} />
|
||||
<DialogClose render={<Button>확인</Button>} />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Section>
|
||||
|
||||
<Section title="드로어(사이드 패널)">
|
||||
<Drawer direction="right">
|
||||
<DrawerTrigger asChild><Button variant="outline">드로어 열기</Button></DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>드로어 타이틀</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
vaul 기반 사이드 드로어. products의 등록/수정 패널이 이걸로 통일돼야 함.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4">
|
||||
<Typography variant="small">패널 본문 영역.</Typography>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>저장</Button>
|
||||
<DrawerClose asChild><Button variant="outline">닫기</Button></DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Section>
|
||||
|
||||
<Section title="토스트">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={() => showToast('성공적으로 처리되었습니다.', 'success')}>
|
||||
success
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => showToast('참고할 정보입니다.', 'info')}>
|
||||
info
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => showToast('오류가 발생했습니다.', 'error')}>
|
||||
error
|
||||
</Button>
|
||||
</div>
|
||||
<Typography variant="muted" className="pt-2">
|
||||
전역 단일 출처: <code className="font-mono">showToast(message, type)</code> (sonner)
|
||||
</Typography>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<Typography variant="h2">{title}</Typography>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Swatch({ name, className }: { name: string; className: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className={`h-14 w-full rounded-md ${className}`} />
|
||||
<Typography variant="mono" className="lowercase tracking-normal">{name}</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Upload, TrendingDown, Download, FileDown, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react';
|
||||
import { Plus, Upload, TrendingDown, Download, FileDown, FileSpreadsheet, ChevronDown, Trash2, ImagePlus } from 'lucide-react';
|
||||
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
@ -18,6 +18,7 @@ import { ProductFormSheet } from '@/features/products/components/ProductFormShee
|
||||
import { PriceUpdateModal } from '@/features/products/components/PriceUpdateModal';
|
||||
import { LowestPriceHistorySheet } from '@/features/products/components/LowestPriceHistorySheet';
|
||||
import { ExcelUploadModal, downloadProductTemplate, downloadProductData } from '@/features/products/components/ExcelUploadModal';
|
||||
import { ImageBulkUploadModal } from '@/features/products/components/ImageBulkUploadModal';
|
||||
import { type Product } from '@/features/products/types';
|
||||
|
||||
export default function ProductsPage() {
|
||||
@ -135,24 +136,29 @@ export default function ProductsPage() {
|
||||
{selectedIds.length > 0 && <Badge variant="destructive">{selectedIds.length}</Badge>}
|
||||
</Button>
|
||||
|
||||
{/* 엑셀 3종 + 이미지 일괄이 한 메뉴에 있어 버튼명은 '일괄 작업'으로 포괄, 항목에서 엑셀/이미지를 명시한다. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
||||
<FileSpreadsheet />
|
||||
엑셀업로드
|
||||
일괄 작업
|
||||
<ChevronDown />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
|
||||
<Upload />
|
||||
일괄 업로드
|
||||
엑셀 일괄 업로드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => downloadProductTemplate(label, settings.item_fields ?? [], isHidden)}>
|
||||
<Download />
|
||||
양식 다운로드
|
||||
엑셀 양식 다운로드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExport}>
|
||||
<FileDown />
|
||||
데이터 다운로드
|
||||
엑셀 데이터 다운로드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => overlay.open('modal', 'images')}>
|
||||
<ImagePlus />
|
||||
이미지 일괄 업로드
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@ -242,6 +248,10 @@ export default function ProductsPage() {
|
||||
onClose={overlay.close}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === 'images' && (
|
||||
<ImageBulkUploadModal open loadProducts={fetchAllForExport} onClose={overlay.close} />
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAuthStore, canManage } from '@/stores/auth';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { PageContainer } from '@/components/layout/PageContainer';
|
||||
@ -30,11 +30,13 @@ export default function QuotationPage() {
|
||||
const mineFilter = list.filters.mine;
|
||||
const statusOptions = QUOTATION_STATUS_OPTIONS;
|
||||
const typeOptions = QUOTATION_TYPE_OPTIONS;
|
||||
// 일반(USER)은 본인 견적만 조회 — 작성자 필터를 숨기고 mine 을 고정한다(백엔드도 동일하게 강제).
|
||||
const isManager = useAuthStore((s) => canManage(s.user?.role));
|
||||
const params: ListQuotationsParams = {
|
||||
search: list.debouncedSearch || undefined,
|
||||
status: statusFilter !== 'ALL' ? statusFilter : undefined,
|
||||
type: typeFilter !== 'ALL' ? typeFilter : undefined,
|
||||
mine: mineFilter === 'MINE' ? true : undefined,
|
||||
mine: !isManager || mineFilter === 'MINE' ? true : undefined,
|
||||
page: list.page,
|
||||
size: list.pageSize,
|
||||
};
|
||||
@ -159,7 +161,8 @@ export default function QuotationPage() {
|
||||
placeholder="견적명 또는 견적 번호로 검색..."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className={`grid gap-2 ${isManager ? 'grid-cols-3' : 'grid-cols-2'}`}>
|
||||
{isManager && (
|
||||
<Select value={mineFilter} onValueChange={(v) => list.setFilter('mine', v as string)}>
|
||||
<SelectTrigger id="quotation-mine-filter">
|
||||
<SelectValue>
|
||||
@ -171,6 +174,7 @@ export default function QuotationPage() {
|
||||
<SelectItem value="MINE">내 견적</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
|
||||
<SelectTrigger id="quotation-status-filter">
|
||||
|
||||
BIN
owner-settings.png
Normal file
BIN
owner-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
quotation-page.png
Normal file
BIN
quotation-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
@ -12,9 +12,7 @@
|
||||
},
|
||||
"branding": {
|
||||
"logo_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/negodata/a35152d2-db61-4760-9f1e-beb9736d957f/items/4ab23e35e4f14d888591ca0f6d343fc8.jpg",
|
||||
"email_header": "iMarketKorea",
|
||||
"service_name": "아이좋아네고",
|
||||
"primary_color": "#f551a0"
|
||||
"service_name": "아이좋아네고"
|
||||
},
|
||||
"item_fields": [
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user