diff --git a/.gitignore b/.gitignore index 3fb70c5..6bb9052 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ CLAUDE.md /mobile.mov .gstack/ +.playwright-mcp/ diff --git a/0729~30_테스트.xlsx b/0729~30_테스트.xlsx new file mode 100644 index 0000000..c544f21 Binary files /dev/null and b/0729~30_테스트.xlsx differ diff --git a/0803_가격협상 우선 적용 및 논의 정리.xlsx b/0803_가격협상 우선 적용 및 논의 정리.xlsx new file mode 100644 index 0000000..701640e Binary files /dev/null and b/0803_가격협상 우선 적용 및 논의 정리.xlsx differ diff --git a/260727_AIO2O 테스트 및 요청사항.xlsx b/260727_AIO2O 테스트 및 요청사항.xlsx new file mode 100644 index 0000000..bfb0075 Binary files /dev/null and b/260727_AIO2O 테스트 및 요청사항.xlsx differ diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py index ad5826c..92783df 100644 --- a/agent/negotiation/chat/infra/repository/nego_context_crud.py +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -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 diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index 1c2af24..89e72dd 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -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}" diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 06d3c3f..ff0cb7d 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -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 와 같아야 표기가 갈리지 않는다) diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py index f42c603..326f46f 100644 --- a/agent/tests/test_context_loader.py +++ b/agent/tests/test_context_loader.py @@ -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 # 협상카드 사용 횟수 상한 로드 확인 diff --git a/backend/router/v1/auth/protocol.py b/backend/router/v1/auth/protocol.py index a4d3d21..74bd60a 100644 --- a/backend/router/v1/auth/protocol.py +++ b/backend/router/v1/auth/protocol.py @@ -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). 한 줄 = 담당자 한 명") diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index 4862af8..12d874e 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -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 diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 2f3f106..4e230a2 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -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"): # 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻. diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 2204447..a786a7b 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -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) diff --git a/dev-settings.png b/dev-settings.png new file mode 100644 index 0000000..47e19be Binary files /dev/null and b/dev-settings.png differ diff --git a/docs/AIO2O-요청사항-반영보고서.pdf b/docs/AIO2O-요청사항-반영보고서.pdf new file mode 100644 index 0000000..238ba6c Binary files /dev/null and b/docs/AIO2O-요청사항-반영보고서.pdf differ diff --git a/docs/aio2o-request-implementation-report.swift b/docs/aio2o-request-implementation-report.swift new file mode 100644 index 0000000..d65ab94 --- /dev/null +++ b/docs/aio2o-request-implementation-report.swift @@ -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() diff --git a/docs/backend-advanced-concepts-ko.pdf b/docs/backend-advanced-concepts-ko.pdf new file mode 100644 index 0000000..9859d34 Binary files /dev/null and b/docs/backend-advanced-concepts-ko.pdf differ diff --git a/docs/backend_advanced_guide.swift b/docs/backend_advanced_guide.swift new file mode 100644 index 0000000..2aba2d2 --- /dev/null +++ b/docs/backend_advanced_guide.swift @@ -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) diff --git a/frontend/src/apis/auth/auth.type.ts b/frontend/src/apis/auth/auth.type.ts index 571dce4..ec40986 100644 --- a/frontend/src/apis/auth/auth.type.ts +++ b/frontend/src/apis/auth/auth.type.ts @@ -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[] } diff --git a/frontend/src/features/auth/hooks/usePreLoginBranding.ts b/frontend/src/features/auth/hooks/usePreLoginBranding.ts index cc37cf9..aa1c16b 100644 --- a/frontend/src/features/auth/hooks/usePreLoginBranding.ts +++ b/frontend/src/features/auth/hooks/usePreLoginBranding.ts @@ -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 diff --git a/frontend/src/features/chat/components/ItemSection.tsx b/frontend/src/features/chat/components/ItemSection.tsx index a2fc245..5b8ac19 100644 --- a/frontend/src/features/chat/components/ItemSection.tsx +++ b/frontend/src/features/chat/components/ItemSection.tsx @@ -110,7 +110,8 @@ function ItemInfo() {
{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)} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 0710b29..68097a4 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -44,7 +44,7 @@ export function MainLayout({ header, children, }: MainLayoutProps) { - const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입 + const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입 — 색은 솔루션 고정 const panes = ( <>
{p.product_code} · 검색 {p.searches}회 - {timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""} + + {/* 못 본 몰이 있으면 이 값은 최종이 아니다 — 가격 옆에서 바로 보여야 오해가 없다 */} + {p.partial && ( + + 일부 확인 못함 + + )} + {timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""} +
@@ -215,6 +226,39 @@ const MALL_COLS = [ const srcColor = (s?: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)"; const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타"); +/** + * 몰별 확인 상태 — 아래 가격표가 **왜 그렇게 생겼는지**를 설명한다. + * 가격표에 없는 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는 이 줄에만 있다. + * 운영 화면이라 상태를 접지 않는다: blocked(자동 회복)와 env_blocked(사람이 고쳐야 함)는 조치가 다르다. + */ +function SourceStates({ sources, partial }: { sources?: Record; partial?: boolean }) { + const entries = Object.entries(sources ?? {}); + if (entries.length === 0) return null; // 이 컬럼 추가 이전 이력 — 조용히 숨긴다 + return ( +
+
+ {entries.map(([mall, info]) => { + const m = stateMeta(info?.state); + return ( + + + {mall} + {m.label} + {info?.count != null && {info.count}건} + + ); + })} +
+ {partial && ( +

+ {unconfirmedMalls(sources).join("·")} 을(를) 확인하지 못했습니다 — 이 최저가는 최종이 아닙니다. +

+ )} +
+ ); +} + function MallCompare({ point }: { point: PricePoint }) { const [topN, setTopN] = useState(5); const malls = (point.by_mall ?? []) @@ -230,6 +274,7 @@ function MallCompare({ point }: { point: PricePoint }) {

{dateShort(point.triggered_at)} 기준 · 그래프의 시점을 클릭해 이동

+ {shown.length === 0 ? (
몰별 데이터 없음
) : ( diff --git a/lps/common/database/model/models.py b/lps/common/database/model/models.py index f458e9b..62cd3a7 100644 --- a/lps/common/database/model/models.py +++ b/lps/common/database/model/models.py @@ -105,6 +105,16 @@ class price_history(MAIN_BASE): # 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...]. # 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지. by_mall = Column(JSONB, nullable=True) + # ── 몰별 '확인했는가' ───────────────────────────────────────────────────── + # by_mall 은 **가격이 있는 몰만** 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지 + # '거기를 못 봤다'인지 알 수 없었다 — 안 본 걸 없다고 말하는 셈이었다. + # {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}} + # state 값은 common.enums.SourceState (정의·표기 규칙은 docs/result-states.md). + sources = Column(JSONB, nullable=True) + # 결과가 완전한가. True = 못 본 몰이 있어 이 값이 최종이 아니다. + # sources 에서 유도 가능하지만 컬럼으로 둔다 — 소비자가 '어떤 상태가 확인된 것인가'라는 + # 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 여기서 끝내고 사실만 넘긴다. + partial = Column(Boolean, nullable=False, server_default=text("false")) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) diff --git a/lps/common/enums.py b/lps/common/enums.py index b275871..035ff90 100644 --- a/lps/common/enums.py +++ b/lps/common/enums.py @@ -69,3 +69,26 @@ class JobType(Enum): SEARCH = 1 # 최저가 검색(쿠팡=브라우저) — 무거움 OUTBOX = 2 # 외부 API 결과 전송(재시도 엔진 공유) — 가벼움 + + +class SourceState(Enum): + """한 상품을 **한 몰에서** 찾은 결과. 정의·표기 규칙은 docs/result-states.md 가 소스다. + + 가장 중요한 경계는 `확인함` 과 `못 봄` 사이다: + MATCHED·NO_MATCH·EMPTY 그 몰을 실제로 봤다 → "없다"고 말해도 되는 사실 + BLOCKED·ENV_BLOCKED·UNAVAILABLE 못 봤다 → "없다"고 말하면 거짓이 된다 + 이 경계를 잃으면 '차단당해 못 본 것'이 '그 몰엔 없음'으로 둔갑한다(실측 문제). + """ + + MATCHED = 1 # 수집·매칭 성공 — 가격 확보 + NO_MATCH = 2 # 수집은 됐으나 같은 상품이 없음(액세서리·다른 규격만) + EMPTY = 3 # 그 몰의 검색 결과 자체가 0건 + BLOCKED = 4 # 안티봇 차단 — IP 회전으로 회복 가능(자동) + ENV_BLOCKED = 5 # 회전해도 안 되는 차단(환경·게이트웨이 설정) — 사람이 고쳐야 함 + UNAVAILABLE = 6 # 전송 실패·가용 IP 없음 등 일시적 — 잠시 후 재시도로 회복 + SKIPPED = 7 # 그 소스를 아예 쓰지 않음(폴백 OFF 등) + + @property + def confirmed(self) -> bool: + """그 몰을 **실제로 확인했는지**. False 면 '없다'고 단정하면 안 된다.""" + return self in (SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY) diff --git a/lps/crud/price_history.py b/lps/crud/price_history.py index 869455c..2bc542f 100644 --- a/lps/crud/price_history.py +++ b/lps/crud/price_history.py @@ -13,6 +13,7 @@ _FIELDS = ( "coupang_lowest", "coupang_name", "coupang_url", "final_lowest", "final_source", "final_rating", "final_review_count", "final_shipping_fee", "final_shipping_type", "final_shipping_label", + "partial", ) @@ -27,17 +28,20 @@ class PriceHistory: naver_lowest, naver_name, naver_url, coupang_lowest, coupang_name, coupang_url, final_lowest, final_source, final_rating, final_review_count, - final_shipping_fee, final_shipping_type, final_shipping_label, by_mall) + final_shipping_fee, final_shipping_type, final_shipping_label, + by_mall, sources, partial) VALUES (:product_code, :job_id, :outcome, :matched_count, :naver_lowest, :naver_name, :naver_url, :coupang_lowest, :coupang_name, :coupang_url, :final_lowest, :final_source, :final_rating, :final_review_count, - :final_shipping_fee, :final_shipping_type, :final_shipping_label, CAST(:by_mall AS jsonb)) + :final_shipping_fee, :final_shipping_type, :final_shipping_label, + CAST(:by_mall AS jsonb), CAST(:sources AS jsonb), COALESCE(:partial, FALSE)) """) params = {k: event.get(k) for k in _FIELDS} - by_mall = event.get("by_mall") - params["by_mall"] = json.dumps(by_mall) if by_mall is not None else None + for col in ("by_mall", "sources"): # JSONB 는 문자열로 넘겨 CAST 한다 + v = event.get(col) + params[col] = json.dumps(v, ensure_ascii=False) if v is not None else None s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) try: await s.execute(sql, params) @@ -55,7 +59,7 @@ class PriceHistory: SELECT triggered_at, outcome, matched_count, naver_lowest, naver_name, naver_url, coupang_lowest, coupang_name, coupang_url, - final_lowest, final_source, by_mall + final_lowest, final_source, by_mall, sources, partial FROM price_history WHERE product_code = :pc ORDER BY triggered_at DESC @@ -76,7 +80,7 @@ class PriceHistory: sql = text(f""" SELECT * FROM ( SELECT DISTINCT ON (product_code) - product_code, triggered_at, outcome, + product_code, triggered_at, outcome, sources, partial, naver_lowest, coupang_lowest, final_lowest, final_source, COALESCE(naver_name, coupang_name) AS display_name, count(*) OVER (PARTITION BY product_code) AS searches diff --git a/lps/docs/database.md b/lps/docs/database.md index 9056a26..5914f9c 100644 --- a/lps/docs/database.md +++ b/lps/docs/database.md @@ -62,6 +62,8 @@ | `final_lowest` | 전체 최저가 (**그래프 Y축 핵심**) | | `final_source` | 최종 최저가가 나온 소스(naver/coupang/gmarket/auction/st11) | | `by_mall` | 몰별 최저가 스냅샷(JSONB, 열린 스키마) — `[{mall, source, price, shipping_fee, shipping_type, url}, …]`. G마켓·옥션·11번가 등이 늘어도 컬럼 추가 없이 담는다 | +| `sources` | **몰별 확인 상태**(JSONB) — `{"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}`. `by_mall` 은 가격이 있는 몰만 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는 이 값에만 있다. state 값은 `SourceState`([정의](result-states.md)) | +| `partial` | 결과가 **완전한가**. `true`=못 본 몰이 있어 최종이 아니다. `sources` 에서 유도 가능하지만 컬럼으로 두어, 소비자가 상태 분류 규칙을 몰라도 되게 한다 | | `job_id` / `created_at` | 검색 잡 연결 / 생성 시각 | > 한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 `null`(그래프 선이 빈다 — 정상). diff --git a/lps/docs/result-states.md b/lps/docs/result-states.md index fdda3ab..3dc3e10 100644 --- a/lps/docs/result-states.md +++ b/lps/docs/result-states.md @@ -157,13 +157,77 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata - 부분 실패 시 네거티브 캐시 오염 방지 - 한 소스가 막혀도 살아있는 소스로 잡을 정상 종료 -**안 된 것** -1. `_search_round` 가 `AdapterError.blocked/fatal` 을 버린다 → 몰별 상태를 못 만든다 *(가장 근본)* -2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다 -3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만) -4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다 +### 진행 상황 -**1 → 2 → (3, 4)** 순서다. 1을 안 고치면 2가 담을 내용이 없고, 2가 없으면 3·4가 읽을 게 없다. -3과 4는 같은 데이터에서 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다. +**1단계 — 몰별 상태 보존 ✅ 완료** (`feat/source-state`) + +- `common/enums.py` 에 `SourceState`(7상태) 추가. `.confirmed` 로 '봤다/못 봤다' 경계를 한곳에 둔다. +- `AdapterError.state` — 어댑터가 이미 알던 구분을 실어 보낸다. `state` 를 안 주면 + `blocked/fatal` 에서 유도하고, **모르면 `UNAVAILABLE`**(= 못 봤다)로 둔다. + `EMPTY` 를 기본값으로 하면 확인도 안 한 몰을 '없음'으로 단정하게 되기 때문이다. +- `browser_base` 의 raise 지점 5곳에 상태를 실었다. 갈림은 0건 종착 지점 하나다: + `blocked=False` → `EMPTY`(정말 없다) / `blocked=True` → `BLOCKED`(못 봤다). +- `_search_round` 가 문자열 대신 `{"state": ..., "count"|"error": ...}` 를 남긴다. + `EMPTY` 는 '정상 응답'으로 세므로, 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않는다. +- `_finalize_states` — 수집만 된 소스를 AI 판정 뒤 `MATCHED`/`NO_MATCH` 로 확정한다. + +검증(9조합 실측): `empty` → `partial=False`(확정) / `blocked`·`env_blocked`·`unavailable` +→ `partial=True`(미확정). 테스트 12건 추가. + +**2단계 — 저장할 자리 ✅ 완료** (`feat/source-state`) + +- `price_history.sources`(JSONB) — 몰별 상태를 그대로 담는다. 열린 스키마라 몰이 늘거나 + 상태에 근거를 덧붙여도 마이그레이션이 필요 없다. +- `price_history.partial`(bool) — 결과가 완전한가. `sources` 에서 유도할 수 있지만 굳이 컬럼으로 + 둔다: 소비자가 '어떤 상태가 확인된 것인가'라는 **판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 + 흩어진다**. 판단은 LPS 가 끝내고 소비자는 사실 하나만 읽는다. +- 부분 인덱스 `ix_price_history_partial` — '확인 못한 결과'만 뽑는 운영 점검용(작게 유지된다). +- 마이그레이션: `postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql` (2026-08 추가분 통합, **운영 적용 필요**) + +검증(실 DB): 쿠팡 차단과 쿠팡 0건은 `by_mall` 이 둘 다 `['naver']` 로 같지만 +`partial`(true/false)과 `sources.coupang.state`(blocked/empty)가 두 경우를 갈라낸다. 테스트 3건 추가. + +**3단계 — lps-admin 상세 표시 ✅ 완료** (`feat/source-state`) + +운영자 목적은 **진단**이라 상태를 접지 않는다 — `blocked`(자동 회복)와 `env_blocked`(사람이 +고쳐야 함)를 뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치한다. + +- API: `/v1/lps/products` 와 `/v1/lps/products/{code}/history` 둘 다 `sources`·`partial` 을 싣는다. + **이력은 시점마다** 실린다 — 최신 상태를 과거 시점 옆에 붙이면 오해를 부르기 때문이다. +- `lps-admin/src/lib/sourceState.ts`: 상태별 라벨·색·설명·`confirmed` 를 한곳에 둔다. + 미지의 상태가 와도 화면이 깨지지 않는다(값 그대로 표시하고 '모름'으로 취급). +- 상품 목록: 못 본 몰이 있으면 `일부 확인 못함` 배지(툴팁에 어느 몰인지). +- 몰별 비교 카드 위: 몰별 상태 줄 + 수집 건수 + 실패 사유 원문(툴팁). 가격표에 없는 몰이 + **왜** 없는지를 여기서 답한다. + +검증: ASGI 직접 호출로 두 엔드포인트 모두 `sources`·`partial` 확인(한글 사유 포함). +`tsc` 오류 없음. 테스트 3건 추가(목록 노출 / 시점별 상태 / 옛 행 호환). + +**4단계 — negodata 사용자 화면 ✅ 완료** (`feat/source-state`) + +3-2절대로 **셋으로 접었다**. 사용자가 할 수 있는 건 '쓴다/다시 시도/넘어간다' 뿐이라, 원인이 +달라도 다음 행동이 같으면 같은 표기다. + +| 몰 칸 | 조건 | +|------|------| +| 가격 | `matched` | +| `–` | `no_match` · `empty` — 확인했고 없었다 | +| **확인 못함** | `blocked` · `env_blocked` · `unavailable` — 못 봤다 | + +체인 전체를 이었다: `price_history.sources/partial` → negodata 읽기 계약 → 동기화 → +`item_internet_lowest_prices` → API(`LowestPriceEntry`) → 화면. +마이그레이션: `postgres-init/alters/2026-08-07-iilp-source-state.sql` (**운영 적용 필요**) + +E2E 검증(실 DB, 두 시나리오): `by_mall` 은 둘 다 `naver` 뿐인데 쿠팡 칸이 +`확인 못함`(차단) / `–`(0건)으로 갈린다. `tsc` 오류 없음, negodata 97 · lps 292 passed. + +> 폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용. + +--- + +**4단계 모두 완료.** 남은 개선 여지(선택): +- `no_match`(같은 상품 아님)와 `empty`(검색 0건)를 사용자 화면에서 굳이 나눌 필요는 없다고 봤다 — + 나중에 "왜 없지?"라는 문의가 잦아지면 재검토. +- 잡 목록(lps-admin Jobs)에는 아직 몰별 상태를 안 붙였다. 상품 화면에서 보이므로 우선순위는 낮다. > 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다. diff --git a/lps/router/v1/lps/admin_protocol.py b/lps/router/v1/lps/admin_protocol.py index c0033fc..206ff65 100644 --- a/lps/router/v1/lps/admin_protocol.py +++ b/lps/router/v1/lps/admin_protocol.py @@ -45,6 +45,10 @@ class ProductItem(BaseModel): final_lowest: Optional[int] = None final_source: Optional[str] = None searches: int = Field(0, description="누적 검색(이력) 수") + # 운영 화면은 **원인까지** 봐야 한다 — '그 몰에 없었다'와 '그 몰을 못 봤다'는 조치가 다르다 + # (전자는 검색어 문제, 후자는 IP·환경 문제). 사용자 화면은 이걸 접어서 보여준다. + sources: Optional[dict] = Field(None, description="몰별 확인 상태 — {몰: {state, count|error}}. state=SourceState") + partial: bool = Field(False, description="못 본 몰이 있어 결과가 최종이 아님") class Res_ProductList(Res_WebPacketProtocol): diff --git a/lps/router/v1/lps/protocol.py b/lps/router/v1/lps/protocol.py index 8deae0c..8113d7c 100644 --- a/lps/router/v1/lps/protocol.py +++ b/lps/router/v1/lps/protocol.py @@ -61,6 +61,9 @@ class PricePoint(BaseModel): coupang_name: Optional[str] = None coupang_url: Optional[str] = None by_mall: Optional[list[dict]] = Field(None, description="몰별 최저가 스냅샷(G마켓·옥션·11번가 등 포함)") + # by_mall 은 **가격이 있는 몰만** 담는다 — 빠진 몰이 '없었다'인지 '못 봤다'인지는 아래에만 있다. + sources: Optional[dict] = Field(None, description="몰별 확인 상태 — {몰: {state, count|error}}") + partial: bool = Field(False, description="못 본 몰이 있어 이 시점 결과가 최종이 아님") class Res_PriceHistory(Res_WebPacketProtocol): diff --git a/lps/services/admin_service.py b/lps/services/admin_service.py index 4f9e22b..9317939 100644 --- a/lps/services/admin_service.py +++ b/lps/services/admin_service.py @@ -87,6 +87,7 @@ class AdminService: naver_lowest=r.get("naver_lowest"), coupang_lowest=r.get("coupang_lowest"), final_lowest=r.get("final_lowest"), final_source=r.get("final_source"), searches=int(r.get("searches") or 0), + sources=r.get("sources"), partial=bool(r.get("partial")), ) for r in await self.history.list_products(q, limit)] return res diff --git a/lps/services/lps_service.py b/lps/services/lps_service.py index 47d29f8..d1fedc8 100644 --- a/lps/services/lps_service.py +++ b/lps/services/lps_service.py @@ -95,6 +95,7 @@ class LpsService: naver_name=r["naver_name"], naver_url=r["naver_url"], coupang_name=r["coupang_name"], coupang_url=r["coupang_url"], by_mall=r.get("by_mall"), + sources=r.get("sources"), partial=bool(r.get("partial")), ) for r in rows ] diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index fb47528..11a0d5c 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -20,6 +20,7 @@ from abc import abstractmethod from patchright.async_api import async_playwright from common.logger import LOG +from common.enums import SourceState from config.server_configs import decodo_config, worker_config from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth from services.search.rate_limiter import RateLimiter @@ -253,7 +254,7 @@ class BrowserSearchAdapter(SearchAdapter): self._note_result(False) raise AdapterError( f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도", - source=self.source) + source=self.source, state=SourceState.UNAVAILABLE) kwargs["proxy"] = self._proxy.playwright_proxy() await self._begin_ip_session(self._proxy.current_port) else: @@ -409,7 +410,9 @@ class BrowserSearchAdapter(SearchAdapter): self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}", kind="proxy_error") continue self._note_result(False) - raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex + # 페이지를 못 받았다 = 그 몰을 **못 봤다**. '없음'과 섞이면 안 된다. + raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source, + state=SourceState.UNAVAILABLE) from ex # 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백 self.last_bytes = self._net_bytes if self._cdp is not None else len(html.encode("utf-8")) @@ -436,7 +439,8 @@ class BrowserSearchAdapter(SearchAdapter): f"[{self.source}] 구조적 차단 '{marker}' — IP 회전으로 회복 불가. " f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)") raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요", - source=self.source, blocked=True, fatal=True) + source=self.source, blocked=True, fatal=True, + state=SourceState.ENV_BLOCKED) # 확신도에 따라 대응을 가른다. # 알려진 마커 사이트가 대놓고 막았다 → 태울 근거가 있다 # short_html 폴백 '0건인데 페이지가 짧다'는 정황일 뿐이다. 진짜 '검색결과 @@ -459,7 +463,8 @@ class BrowserSearchAdapter(SearchAdapter): raise AdapterError( f"{self.source} 환경 차단 (IP {len(self._fresh_ip_blocks)}개가 첫 요청부터 차단, " f"최근 마커={marker}) — IP 회전으로 회복 불가", - source=self.source, blocked=True, fatal=True) + source=self.source, blocked=True, fatal=True, + state=SourceState.ENV_BLOCKED) if known and self.uses_proxy: self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀) @@ -471,7 +476,13 @@ class BrowserSearchAdapter(SearchAdapter): if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기 self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block") self._note_result(False) - raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", source=self.source, blocked=blocked) + # 여기가 '없음'과 '못 봄'이 갈리는 유일한 지점이다. + # blocked=False → 페이지는 정상인데 상품이 0건 = 그 몰에 **정말 없다**(EMPTY) + # blocked=True → 차단 페이지를 받은 것 = **못 봤다**(BLOCKED) + # 이 구분을 여기서 안 실어 보내면 위쪽에서는 영영 알 수 없다. + raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", + source=self.source, blocked=blocked, + state=SourceState.BLOCKED if blocked else SourceState.EMPTY) async def _report_detection(self, query: str, marker: str, html_len: int): # 경과는 **IP 세션** 기준 — 'ip_req#N 을 몇 초 만에 쐈나'가 차단 진단의 축이다. diff --git a/lps/services/search/contract.py b/lps/services/search/contract.py index 3f37a5d..5086955 100644 --- a/lps/services/search/contract.py +++ b/lps/services/search/contract.py @@ -12,6 +12,8 @@ from typing import Optional from pydantic import BaseModel, Field +from common.enums import SourceState + class NormalizedProduct(BaseModel): """소스 무관 정규화 상품 스키마. 어댑터의 유일한 출력 계약.""" @@ -55,13 +57,25 @@ class AdapterError(Exception): fatal=True 는 **재시도해도 절대 안 되는 차단**이다 — IP 를 바꿔도 같은 결과가 나오는 구조적 원인(예: 네이버 msearch 에 해외 IP 로 접근 = 게이트웨이 설정이 틀림). 호출부는 회전·재시도를 멈추고 설정을 고쳐야 한다. + + ⚠️ `state` 는 **'그 몰을 봤는가'** 를 담는다. blocked/fatal 은 '어떻게 대응할까'(회전·재시도)를 + 위한 값이고, state 는 '사용자에게 뭐라고 말할까'를 위한 값이라 쓰임이 다르다. + 특히 blocked=False 하나에 두 가지가 섞여 있어 state 없이는 갈라낼 수 없다: + 결과 0건(EMPTY) 그 몰을 봤고 정말 없었다 → "없음"이라 말해도 된다 + 전송 실패(UNAVAILABLE) 그 몰을 못 봤다 → "없음"이라 말하면 거짓 """ - def __init__(self, message: str, *, source: str, blocked: bool = False, fatal: bool = False): + def __init__(self, message: str, *, source: str, blocked: bool = False, fatal: bool = False, + state: SourceState | None = None): super().__init__(message) self.source = source self.blocked = blocked self.fatal = fatal + # state 를 안 준 옛 호출부도 맞게 동작하도록 blocked/fatal 에서 유도한다. + # (모르면 UNAVAILABLE — '못 봤다' 쪽이 안전한 기본값이다. EMPTY 로 잘못 넘기면 + # 확인도 안 한 몰을 '없음'으로 단정하게 된다) + self.state = state or (SourceState.ENV_BLOCKED if fatal else + SourceState.BLOCKED if blocked else SourceState.UNAVAILABLE) class SearchAdapter(ABC): diff --git a/lps/tests/test_admin_api.py b/lps/tests/test_admin_api.py index 9205377..3fb33c7 100644 --- a/lps/tests/test_admin_api.py +++ b/lps/tests/test_admin_api.py @@ -95,6 +95,52 @@ async def test_products_list_latest_snapshot(client, clean_all): assert len(r.json()["items"]) == 1 +# ---- 몰별 확인 상태 (2026-08-07, 3단계) ------------------------------------- +# 운영 화면은 '왜 그 몰 값이 없나'에 답할 수 있어야 한다 — by_mall 에 없는 몰이 +# '거기엔 없더라'인지 '거기를 못 봤다'인지는 sources 에만 있다. + +async def test_products_list_exposes_source_states(client, clean_all): + async with clean_all.begin() as conn: + await conn.execute(text(""" + INSERT INTO price_history (product_code, outcome, final_lowest, partial, sources, triggered_at) + VALUES ('S1', 'found', 9000, true, + '{"naver": {"state": "matched", "count": 40}, + "coupang": {"state": "env_blocked", "error": "사용권한이 제한된"}}'::jsonb, + now()) + """)) + item = (await client.get("/v1/lps/products")).json()["items"][0] + assert item["partial"] is True + assert item["sources"]["coupang"]["state"] == "env_blocked" + assert "사용권한이 제한된" in item["sources"]["coupang"]["error"] # 원인 원문이 운영자에게 간다 + + +async def test_history_points_carry_state_per_point(client, clean_all): + """상태는 시점마다 다르다 — 최신 상태를 과거 시점 옆에 붙이면 오해를 부른다.""" + async with clean_all.begin() as conn: + await conn.execute(text(""" + INSERT INTO price_history (product_code, outcome, final_lowest, partial, sources, triggered_at) + VALUES ('S2', 'found', 9000, true, + '{"coupang": {"state": "blocked"}}'::jsonb, now() - interval '1 hour'), + ('S2', 'found', 8500, false, + '{"coupang": {"state": "matched", "count": 60}}'::jsonb, now()) + """)) + pts = (await client.get("/v1/lps/products/S2/history")).json()["points"] + assert [p["partial"] for p in pts] == [True, False] + assert pts[0]["sources"]["coupang"]["state"] == "blocked" + assert pts[1]["sources"]["coupang"]["state"] == "matched" + + +async def test_old_rows_without_state_still_work(client, clean_all): + """이 컬럼 추가 이전 이력도 그대로 읽혀야 한다(마이그레이션 전 데이터).""" + async with clean_all.begin() as conn: + await conn.execute(text(""" + INSERT INTO price_history (product_code, outcome, final_lowest, triggered_at) + VALUES ('S3', 'found', 7000, now()) + """)) + item = (await client.get("/v1/lps/products")).json()["items"][0] + assert item["partial"] is False and item.get("sources") is None + + # ---- 통계 3종 --------------------------------------------------------------- async def test_ip_session_stats(client, clean_all): diff --git a/lps/tests/test_price_history.py b/lps/tests/test_price_history.py index bc6b032..ad4db89 100644 --- a/lps/tests/test_price_history.py +++ b/lps/tests/test_price_history.py @@ -113,3 +113,42 @@ async def test_snapshot_carries_trust_of_the_lowest_offer(): e = rec.events[0] assert e["final_lowest"] == 900 and e["final_source"] == "naver" assert e["final_rating"] is None and e["final_review_count"] is None # 미검증 오퍼임이 드러난다 + + +# ── 몰별 확인 상태 (2026-08-07, 2단계) ────────────────────────────────── +# by_mall 은 '가격이 있는 몰'만 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지 +# '거기를 못 봤다'인지 구분되지 않았다 — sources/partial 이 그 자리를 메운다. + +async def test_records_source_states_and_partial(ph, db_engine): + await ph.record({ + "product_code": "SRC1", "outcome": "found", "final_lowest": 9000, "partial": True, + "sources": {"naver": {"state": "matched", "count": 40}, + "coupang": {"state": "blocked", "error": "AdapterError: 차단"}}, + }) + async with db_engine.begin() as conn: + row = (await conn.execute(text( + "SELECT partial, sources FROM price_history WHERE product_code='SRC1'"))).first() + assert row.partial is True + assert row.sources["coupang"]["state"] == "blocked" + assert row.sources["naver"]["count"] == 40 + + +async def test_partial_defaults_to_false_when_absent(ph, db_engine): + """옛 호출부(값을 안 주는 경로)도 깨지지 않아야 한다 — 기본은 '완전한 결과'.""" + await ph.record({"product_code": "SRC2", "outcome": "not_found"}) + async with db_engine.begin() as conn: + row = (await conn.execute(text( + "SELECT partial, sources FROM price_history WHERE product_code='SRC2'"))).first() + assert row.partial is False and row.sources is None + + +async def test_source_state_survives_korean_text(ph, db_engine): + """error 메시지에 한글이 섞여도 JSONB 가 깨지지 않아야 한다(ensure_ascii=False).""" + await ph.record({ + "product_code": "SRC3", "outcome": "found", "partial": True, + "sources": {"coupang": {"state": "env_blocked", "error": "사용권한이 제한된 페이지"}}, + }) + async with db_engine.begin() as conn: + row = (await conn.execute(text( + "SELECT sources FROM price_history WHERE product_code='SRC3'"))).first() + assert "사용권한이 제한된" in row.sources["coupang"]["error"] diff --git a/lps/tests/test_source_state.py b/lps/tests/test_source_state.py new file mode 100644 index 0000000..d8deffc --- /dev/null +++ b/lps/tests/test_source_state.py @@ -0,0 +1,107 @@ +"""몰(소스)별 상태 테스트 — '그 몰에 없었다'와 '그 몰을 못 봤다'를 가르는 계약. + +정의는 docs/result-states.md 가 소스다. 여기서 지키는 것은 하나다: +**확인한 것(matched/no_match/empty)과 못 본 것(blocked/env_blocked/unavailable)이 섞이지 않는다.** +섞이면 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'으로 둔갑한다(실제로 그랬다). +""" + +import pytest + +from common.enums import SourceState +from services.search.contract import AdapterError, NormalizedProduct, SearchAdapter +from worker.handlers import _finalize_states, build_search_handler + + +# ── 상태 자체의 계약 ──────────────────────────────────────────────────── +def test_confirmed_separates_seen_from_unseen(): + """이 경계가 무너지면 나머지 로직이 전부 틀어진다.""" + assert [s for s in SourceState if s.confirmed] == [ + SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY] + for s in (SourceState.BLOCKED, SourceState.ENV_BLOCKED, SourceState.UNAVAILABLE, SourceState.SKIPPED): + assert not s.confirmed, s + + +# ── AdapterError 가 상태를 싣는가 ─────────────────────────────────────── +@pytest.mark.parametrize("kw,expected", [ + (dict(), SourceState.UNAVAILABLE), # 아무것도 모르면 '못 봤다'가 안전 + (dict(blocked=True), SourceState.BLOCKED), + (dict(blocked=True, fatal=True), SourceState.ENV_BLOCKED), + (dict(state=SourceState.EMPTY), SourceState.EMPTY), +]) +def test_error_state_defaults(kw, expected): + """state 를 안 준 옛 호출부도 맞게 동작해야 한다 — 특히 기본값이 EMPTY 면 안 된다 + (확인도 안 한 몰을 '없음'으로 단정하게 된다).""" + assert AdapterError("x", source="s", **kw).state is expected + + +# ── 수집 뒤 매칭 결과로 확정 ──────────────────────────────────────────── +def test_collected_but_unmatched_becomes_no_match(): + per_source = {"naver": {"state": "matched"}, "coupang": {"state": "matched"}} + matched = [NormalizedProduct(source="naver", name="x", price=1)] + _finalize_states(per_source, matched) + assert per_source["naver"]["state"] == "matched" + assert per_source["coupang"]["state"] == "no_match" # 가져왔지만 같은 상품이 아니었다 + + +def test_finalize_never_overwrites_a_failure_state(): + """실패 상태는 이미 확정이다 — 매칭 결과로 덮으면 '못 봤다'가 사라진다.""" + per_source = {"coupang": {"state": "blocked"}, "naver": {"state": "empty"}} + _finalize_states(per_source, []) + assert per_source["coupang"]["state"] == "blocked" + assert per_source["naver"]["state"] == "empty" + + +# ── 핸들러 통합: 상태가 결과까지 실려 나가는가 ────────────────────────── +class _A(SearchAdapter): + def __init__(self, source, mode): self.source, self.mode = source, mode + + async def search(self, q, limit=40): + if self.mode == "hit": + return [NormalizedProduct(source=self.source, name="생수 2L", price=9000)] + if self.mode == "empty": + raise AdapterError("결과 없음", source=self.source, state=SourceState.EMPTY) + if self.mode == "blocked": + raise AdapterError("차단", source=self.source, blocked=True, state=SourceState.BLOCKED) + raise RuntimeError("우리 코드 버그") + + +def _job(): + return {"job_type": 1, "job_id": "J", "attempts": 1, "max_attempts": 3, + "payload": {"product_code": "P", "product_name": "생수"}} + + +async def _run(modes): + return await build_search_handler({s: _A(s, m) for s, m in modes.items()})(_job()) + + +async def test_empty_is_a_confirmed_answer_not_a_gap(): + """봤는데 없었으면 결과는 **확정**이다 — partial 로 흐리면 안 된다.""" + r = await _run({"naver": "hit", "coupang": "empty"}) + assert r["sources"]["coupang"]["state"] == "empty" + assert r["partial"] is False and r["sources_failed"] == [] + + +async def test_blocked_marks_the_result_incomplete(): + """못 본 몰이 있으면 그 결과는 최종이 아니다.""" + r = await _run({"naver": "hit", "coupang": "blocked"}) + assert r["sources"]["coupang"]["state"] == "blocked" + assert r["partial"] is True and r["sources_failed"] == ["coupang"] + + +async def test_unknown_exception_counts_as_unseen(): + """우리 코드 버그로 못 본 것도 '없음'이라 말하면 안 된다.""" + r = await _run({"naver": "hit", "coupang": "boom"}) + assert r["sources"]["coupang"]["state"] == "unavailable" + assert r["partial"] is True + + +async def test_all_empty_is_a_definitive_not_found(): + """전부 확인했고 없었다 → 확정적 not_found(네거티브 캐시에 넣어도 되는 상태).""" + r = await _run({"naver": "empty", "coupang": "empty"}) + assert r["outcome"] == "not_found" and r["partial"] is False + + +async def test_all_blocked_is_not_an_answer(): + """전부 못 봤으면 '없음'이 아니라 '모름'이다 — 재시도해야 한다.""" + with pytest.raises(RuntimeError): + await _run({"naver": "blocked", "coupang": "blocked"}) diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 8d6098c..e363741 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -16,7 +16,7 @@ import asyncio import time -from common.enums import JobType +from common.enums import JobType, SourceState from common.logger import LOG from services.metrics import SearchMetrics from services.search.contract import SearchAdapter, NormalizedProduct @@ -40,6 +40,20 @@ def _reap_abandoned(task: asyncio.Task): LOG.d(f"[fallback] 데드라인 초과 태스크 종료(무시): {type(ex).__name__}") +def _finalize_states(per_source: dict, matched: list[NormalizedProduct]) -> dict: + """수집 성공 소스의 상태를 **매칭 결과로 확정**한다(제자리 수정 후 반환). + + 수집 단계에선 '가져왔다'까지만 알 수 있다. '같은 상품이었나'는 AI 판정을 거쳐야 알므로 + 여기서 MATCHED / NO_MATCH 를 가른다. 실패 상태(BLOCKED·EMPTY 등)는 이미 확정이라 건드리지 않는다. + """ + hit = {p.source for p in matched} + collected = SourceState.MATCHED.name.lower() + for src, info in per_source.items(): + if info.get("state") == collected and src not in hit: + info["state"] = SourceState.NO_MATCH.name.lower() + return per_source + + def _price_snapshot(matched: list[NormalizedProduct]) -> dict: """매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용).""" def lowest(src): @@ -87,10 +101,20 @@ def build_search_handler( use = list(sources) if sources else list(adapters.keys()) fallbacks = fallback_adapters or {} - async def _record_history(product_code: str, job_id, outcome: str, matched: list): + async def _record_history(product_code: str, job_id, outcome: str, matched: list, + sources: dict | None = None): + """price_history 1행 기록. + + sources 를 함께 남기는 게 중요하다 — by_mall 은 **가격이 있는 몰만** 담으므로, 어떤 몰이 + 빠졌을 때 '거기엔 없더라'인지 '거기를 못 봤다'인지 이 값 없이는 알 수 없다. + partial 은 여기서 판단해 사실로 넘긴다(소비자가 상태 분류 규칙을 알 필요 없게). + """ if history is None: return - event = {"product_code": product_code, "job_id": job_id, "outcome": outcome, **_price_snapshot(matched)} + confirmed = {s.name.lower() for s in SourceState if s.confirmed} + partial = any((i or {}).get("state") not in confirmed for i in (sources or {}).values()) + event = {"product_code": product_code, "job_id": job_id, "outcome": outcome, + "sources": sources or None, "partial": partial, **_price_snapshot(matched)} try: await history.record(event) except Exception as ex: @@ -119,11 +143,17 @@ def build_search_handler( products, per_source, ok_sources = [], {}, [] for src, res in zip(use, results): if isinstance(res, Exception): - per_source[src] = {"error": f"{type(res).__name__}: {res}"} - LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}") + # ⚠️ 예외를 문자열로만 남기면 '그 몰에 없었다'와 '그 몰을 못 봤다'가 같아진다. + # AdapterError 는 이미 state 로 그걸 알고 있으므로 **구조화해서 보존**한다. + # (AdapterError 가 아닌 예외 = 우리 코드 버그 → 못 본 것으로 본다) + state = getattr(res, "state", None) or SourceState.UNAVAILABLE + per_source[src] = {"state": state.name.lower(), "error": f"{type(res).__name__}: {res}"} + LOG.w(f"[{src}] 검색 실패({state.name}): {type(res).__name__}: {res}") + if state.confirmed: + ok_sources.append(src) # EMPTY = 봤는데 없던 것 → '정상 응답'으로 센다 else: products.extend(res) - per_source[src] = {"count": len(res)} + per_source[src] = {"state": SourceState.MATCHED.name.lower(), "count": len(res)} ok_sources.append(src) return products, per_source, ok_sources @@ -233,6 +263,7 @@ def build_search_handler( matched = [c for c, v in zip(candidates, verdicts) if v.is_match] stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)}) candidates = matched + _finalize_states(per_source, candidates) # 수집만 된 소스를 matched/no_match 로 확정 last_stages, last_sources = stages, per_source last_ok, last_failed = ok_sources, failed_sources @@ -247,7 +278,7 @@ def build_search_handler( partial=bool(failed_sources), metrics=metrics.snapshot()) if failed_sources: LOG.w(f"[partial] {failed_sources} 없이 결과를 냈습니다 — 그 몰의 더 싼 값은 못 봤을 수 있습니다") - await _record_history(cache_key, job.get("job_id"), "found", candidates) + await _record_history(cache_key, job.get("job_id"), "found", candidates, per_source) return result # 0매칭 + 일부 소스 실패. **살아있는 소스가 하나라도 있으면 그 결과로 진행한다** — @@ -266,7 +297,7 @@ def build_search_handler( result.update(outcome="error", query=query, rounds_tried=rounds_done, sources=per_source, sources_ok=[], sources_failed=failed_sources, partial=True, metrics=metrics.snapshot()) - await _record_history(cache_key, job.get("job_id"), "error", []) + await _record_history(cache_key, job.get("job_id"), "error", [], per_source) return result raise RuntimeError(f"모든 소스 실패로 0매칭(round={label}) — 잡 재시도: {per_source}") @@ -282,7 +313,7 @@ def build_search_handler( partial=partial, metrics=metrics.snapshot()) if partial: LOG.w(f"[partial] {last_failed} 없이 not_found — 네거티브 캐시는 건너뜁니다(그 몰엔 있었을 수 있음)") - await _record_history(cache_key, job.get("job_id"), "not_found", []) + await _record_history(cache_key, job.get("job_id"), "not_found", [], last_sources) return result return handler diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 1a327e4..f7cb178 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -136,8 +136,16 @@ class item_internet_lowest_prices(MainTableMixin, MAIN_BASE): lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용 # 몰별 최저가 스냅샷 — lps_db.price_history.by_mall 을 그대로 미러링한다(열린 스키마). # [{source, mall_name, price, shipping_fee, shipping_type, name, detail_url}, ...] 가격 오름차순. - # 매칭된 몰만 들어오므로, 특정 몰이 없으면 그 몰은 빈손이었다는 뜻이다(네이버 실패/쿠팡만 성공 구분). + # 매칭된 몰만 들어온다. 그래서 **특정 몰이 없는 이유**는 여기서 알 수 없다 — + # '그 몰엔 없었다'인지 '그 몰을 못 봤다(차단)'인지는 아래 sources 가 답한다. by_mall = Column(JSONB, nullable=True) + # 몰별 확인 상태 — lps_db.price_history.sources 미러링(열린 스키마). + # {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}} + # state 값 정의는 lps/docs/result-states.md (LPS common.enums.SourceState). + sources = Column(JSONB, nullable=True) + # 못 본 몰이 있어 결과가 최종이 아님. LPS 가 판단해 내려준 사실을 그대로 싣는다 — + # 화면이 '어떤 상태가 확인된 것인가'를 다시 판정하면 상태 정의가 두 곳으로 흩어진다. + partial = Column(Boolean, nullable=False, server_default=text("false")) crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준) diff --git a/negodata/backend/crud/lps_sync_crud.py b/negodata/backend/crud/lps_sync_crud.py index 94126e6..42f63a7 100644 --- a/negodata/backend/crud/lps_sync_crud.py +++ b/negodata/backend/crud/lps_sync_crud.py @@ -29,7 +29,9 @@ _price_history = table( column("naver_url"), column("coupang_name"), # 쿠팡 최저가 상품명/링크 column("coupang_url"), - column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 어느 몰이 건졌고 어느 쪽이 빈손인지 + column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 가격이 **있는** 몰만 + column("sources"), # 몰별 확인 상태 — 빠진 몰이 '없었다'인지 '못 봤다'인지는 여기에만 있다 + column("partial"), # 못 본 몰이 있어 결과가 최종이 아님(LPS 판단 결과) column("created_at"), ) @@ -118,6 +120,8 @@ class LpsSyncCRUD(ILpsSyncCRUD): _price_history.c.coupang_name, _price_history.c.coupang_url, _price_history.c.by_mall, + _price_history.c.sources, + _price_history.c.partial, _price_history.c.created_at, ).order_by(_price_history.c.created_at.asc()) if since is not None: diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 58b12f4..ae68ab2 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -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: diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt index fa46e28..831053b 100644 --- a/negodata/backend/requirements.txt +++ b/negodata/backend/requirements.txt @@ -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) diff --git a/negodata/backend/router/v1/company/protocol.py b/negodata/backend/router/v1/company/protocol.py index d6294a4..09a51e3 100644 --- a/negodata/backend/router/v1/company/protocol.py +++ b/negodata/backend/router/v1/company/protocol.py @@ -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 = "" diff --git a/negodata/backend/router/v1/company/settings.py b/negodata/backend/router/v1/company/settings.py index 8409806..466f00d 100644 --- a/negodata/backend/router/v1/company/settings.py +++ b/negodata/backend/router/v1/company/settings.py @@ -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)) diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index 1ef773d..930540f 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -145,9 +145,16 @@ class LowestPriceEntry(WebPacketProtocol): fail_reason: Optional[str] = None lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용 lp_url: Optional[str] = None # 찾은 판매 페이지 링크 - # 몰별 최저가 스냅샷(가격 오름차순). 매칭된 몰만 들어오므로, 없는 몰은 그 회차에 빈손이었다는 뜻. + # 몰별 최저가 스냅샷(가격 오름차순). **가격이 있는 몰만** 들어온다 — + # 그래서 없는 몰이 '거기엔 없었다'인지 '거기를 못 봤다'인지는 아래 sources 가 답한다. # [{source: 'naver'|'coupang'|…, mall_name, price, shipping_fee, shipping_type, name, detail_url}] by_mall: Optional[list[dict]] = None + # 몰별 확인 상태 — {"naver": {"state": "matched"}, "coupang": {"state": "blocked"}}. + # 화면은 이걸 셋으로 접어 쓴다: 가격 / '–'(없음) / '확인 못함'(미확인). + # state 정의는 lps/docs/result-states.md. + sources: Optional[dict] = None + # 못 본 몰이 있어 결과가 최종이 아님. LPS 가 판단한 사실이라 화면은 그대로 쓰면 된다. + partial: bool = False crawl_end_time: Optional[datetime] = None diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index cdb2422..db832de 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -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)) diff --git a/negodata/backend/services/company_settings_service.py b/negodata/backend/services/company_settings_service.py index 188d725..1f90ad0 100644 --- a/negodata/backend/services/company_settings_service.py +++ b/negodata/backend/services/company_settings_service.py @@ -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 diff --git a/negodata/backend/services/email.py b/negodata/backend/services/email.py index 9f2db41..b98d8fb 100644 --- a/negodata/backend/services/email.py +++ b/negodata/backend/services/email.py @@ -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'{escape(name)}' + ) + return f'{escape(name)}' diff --git a/negodata/backend/services/email_templates/invite_email.html b/negodata/backend/services/email_templates/invite_email.html index 7aa45dd..f4e2116 100644 --- a/negodata/backend/services/email_templates/invite_email.html +++ b/negodata/backend/services/email_templates/invite_email.html @@ -1,37 +1,43 @@ - +
- +
- + - + + + + +
- $email_header +  
+ $header_content +

negotium B2B 구매협상 솔루션

-

협상 참여 요청

-

- $supplier_name 담당자님,
- 아래 견적 건의 협상에 참여해 주세요. +

협상 참여 요청

+

+ $supplier_name 담당자님,
+ $email_greeting

- +
- - + + - - + + - - + +
견적명$quotation_name견적명$quotation_name
견적번호$qt_number견적번호$qt_number
협상 마감$deadline협상 마감$deadline
@@ -41,9 +47,8 @@
-
- - 협상 참여하기 → + + 협상 참여하기 →
@@ -56,15 +61,14 @@
-

+

버튼이 열리지 않으면 아래 링크를 복사해 접속하세요.
- https://nego.o2o.kr + $chat_url

-

본 메일은 협상 견적 시스템에서 자동 발송되었습니다.

diff --git a/negodata/backend/services/lps_sync_service.py b/negodata/backend/services/lps_sync_service.py index 4559ffd..fdd8e7c 100644 --- a/negodata/backend/services/lps_sync_service.py +++ b/negodata/backend/services/lps_sync_service.py @@ -159,7 +159,8 @@ class LpsSyncService: # product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵 parsed = [] - for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, by_mall, created_at in rows: + for (code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, + by_mall, sources, partial, created_at) in rows: try: iid = uuid.UUID(code) except (ValueError, AttributeError, TypeError): @@ -170,7 +171,8 @@ class LpsSyncService: "naver": (nv_name, nv_url), "coupang": (cp_name, cp_url), }.get((final_source or "").lower(), (None, None)) - parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, by_mall, created_at)) + parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, + by_mall, sources, partial, created_at)) err, existing = await DB_SESSION_MNG.execute_lambda( DBType.MAIN.value, DBWRType.DB_READ.value, @@ -180,7 +182,8 @@ class LpsSyncService: return results history_rows, latest_found = [], {} # latest_found: item_id → (created_at, price) - for item_id, outcome, final_lowest, final_source, src_name, src_url, by_mall, created_at in parsed: + for (item_id, outcome, final_lowest, final_source, src_name, src_url, + by_mall, sources, partial, created_at) in parsed: if item_id not in existing: results["skipped_unknown_item"] += 1 continue @@ -194,6 +197,10 @@ class LpsSyncService: lp_name=(src_name or None) and src_name[:300], lp_url=src_url or None, by_mall=by_mall or None, # 몰별 스냅샷 그대로 미러링 — 몰별 성공/실패 표시용 + # 몰별 확인 상태도 그대로 옮긴다. by_mall 에 없는 몰이 '없었다'인지 '못 봤다'인지는 + # 이 값에만 있어, 없으면 화면이 두 경우를 구분할 방법이 없다. + sources=sources or None, + partial=bool(partial), crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존 )) results["found" if found else "not_found"] += 1 diff --git a/negodata/backend/services/quotation/invites.py b/negodata/backend/services/quotation/invites.py index ad04fc9..aa40761 100644 --- a/negodata/backend/services/quotation/invites.py +++ b/negodata/backend/services/quotation/invites.py @@ -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) diff --git a/negodata/backend/services/quotation/pricing.py b/negodata/backend/services/quotation/pricing.py index 3a3472a..e0c48fc 100644 --- a/negodata/backend/services/quotation/pricing.py +++ b/negodata/backend/services/quotation/pricing.py @@ -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 diff --git a/negodata/backend/services/quotation/queries.py b/negodata/backend/services/quotation/queries.py index a192b80..f640205 100644 --- a/negodata/backend/services/quotation/queries.py +++ b/negodata/backend/services/quotation/queries.py @@ -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 diff --git a/negodata/backend/tests/test_invite_email_preview.py b/negodata/backend/tests/test_invite_email_preview.py new file mode 100644 index 0000000..f89f8e1 --- /dev/null +++ b/negodata/backend/tests/test_invite_email_preview.py @@ -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 ' 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 diff --git a/negodata/docs/anchoring-customization-design.pdf b/negodata/docs/anchoring-customization-design.pdf new file mode 100644 index 0000000..c080efd Binary files /dev/null and b/negodata/docs/anchoring-customization-design.pdf differ diff --git a/negodata/front/src/api/generated/company-settings/company-settings.ts b/negodata/front/src/api/generated/company-settings/company-settings.ts index 7196052..c9bf8b3 100644 --- a/negodata/front/src/api/generated/company-settings/company-settings.ts +++ b/negodata/front/src/api/generated/company-settings/company-settings.ts @@ -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 = ,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/settings/email-preview`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqPreviewInviteEmail, signal + }, + options); + } + + + +export const getPreviewInviteEmailMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqPreviewInviteEmail}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {data: ReqPreviewInviteEmail}> = (props) => { + const {data} = props ?? {}; + + return previewInviteEmail(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type PreviewInviteEmailMutationResult = NonNullable>> + export type PreviewInviteEmailMutationBody = ReqPreviewInviteEmail + export type PreviewInviteEmailMutationError = void | HTTPValidationError + + /** + * @summary 협상 초청 메일 미리보기 + */ +export const usePreviewInviteEmail = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqPreviewInviteEmail}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqPreviewInviteEmail}, + TContext + > => { + + const mutationOptions = getPreviewInviteEmailMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 924c176..4304c83 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -92,6 +92,8 @@ export * from './lowestPriceEntryFailReason'; export * from './lowestPriceEntryLpName'; export * from './lowestPriceEntryLpPrice'; export * from './lowestPriceEntryLpUrl'; +export * from './lowestPriceEntrySources'; +export * from './lowestPriceEntrySourcesAnyOf'; export * from './notificationData'; export * from './notificationDataCreatedAt'; export * from './notificationDataData'; @@ -201,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'; @@ -342,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'; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntry.ts b/negodata/front/src/api/generated/model/lowestPriceEntry.ts index 105c96f..c793d83 100644 --- a/negodata/front/src/api/generated/model/lowestPriceEntry.ts +++ b/negodata/front/src/api/generated/model/lowestPriceEntry.ts @@ -9,6 +9,7 @@ import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason'; import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName'; import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl'; import type { LowestPriceEntryByMall } from './lowestPriceEntryByMall'; +import type { LowestPriceEntrySources } from './lowestPriceEntrySources'; import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime'; /** @@ -22,5 +23,7 @@ export interface LowestPriceEntry { lp_name?: LowestPriceEntryLpName; lp_url?: LowestPriceEntryLpUrl; by_mall?: LowestPriceEntryByMall; + sources?: LowestPriceEntrySources; + partial?: boolean; crawl_end_time?: LowestPriceEntryCrawlEndTime; } diff --git a/negodata/front/src/api/generated/model/lowestPriceEntrySources.ts b/negodata/front/src/api/generated/model/lowestPriceEntrySources.ts new file mode 100644 index 0000000..19dabb2 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntrySources.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { LowestPriceEntrySourcesAnyOf } from './lowestPriceEntrySourcesAnyOf'; + +export type LowestPriceEntrySources = LowestPriceEntrySourcesAnyOf | null; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntrySourcesAnyOf.ts b/negodata/front/src/api/generated/model/lowestPriceEntrySourcesAnyOf.ts new file mode 100644 index 0000000..7f1c1a6 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntrySourcesAnyOf.ts @@ -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 LowestPriceEntrySourcesAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/reqPreviewInviteEmail.ts b/negodata/front/src/api/generated/model/reqPreviewInviteEmail.ts new file mode 100644 index 0000000..d4e0c11 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqPreviewInviteEmail.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/reqPreviewInviteEmailBranding.ts b/negodata/front/src/api/generated/model/reqPreviewInviteEmailBranding.ts new file mode 100644 index 0000000..3601636 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqPreviewInviteEmailBranding.ts @@ -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 }; diff --git a/negodata/front/src/api/generated/model/resPreviewInviteEmail.ts b/negodata/front/src/api/generated/model/resPreviewInviteEmail.ts new file mode 100644 index 0000000..3f04ab9 --- /dev/null +++ b/negodata/front/src/api/generated/model/resPreviewInviteEmail.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/resPreviewInviteEmailMsg.ts b/negodata/front/src/api/generated/model/resPreviewInviteEmailMsg.ts new file mode 100644 index 0000000..3bfe6d7 --- /dev/null +++ b/negodata/front/src/api/generated/model/resPreviewInviteEmailMsg.ts @@ -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; diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index f2c8a6d..df77d1f 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -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'), diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 3ff0631..fc47e5f 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -180,11 +180,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay {branding.logoUrl ? ( {branding.serviceName} ) : ( - + )} {branding.serviceName} @@ -291,7 +287,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay {/* Main Container Wrapper */}
- + {/* 기준일시·관리자는 xl 부터 — lg 구간(1024~1280)은 우측 1fr 이 ~240px 라 글자가 세로로 깨진다. */} + 기준일시: {today} @@ -603,8 +602,8 @@ function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
-
-
+
+
관리자: {user?.name} ({user?.loginId}) diff --git a/negodata/front/src/features/onboarding/components/DetailSteps.tsx b/negodata/front/src/features/onboarding/components/DetailSteps.tsx index 0960702..4d8ea1f 100644 --- a/negodata/front/src/features/onboarding/components/DetailSteps.tsx +++ b/negodata/front/src/features/onboarding/components/DetailSteps.tsx @@ -236,7 +236,7 @@ const ZONE_STYLE: Record Promise; // 전체 상품(페이지 밖 포함) — 매칭·권한 판정용 + 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([]); + const [phase, setPhase] = useState('select'); + const [overwrite, setOverwrite] = useState(true); // 대부분 placeholder 이미지라 기본 덮어쓰기 + const [matching, setMatching] = useState(false); + const fileRef = useRef(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 ( +
+
+ {/* 헤더 */} +
+ 상품 이미지 일괄 업로드 + +
+ + + 파일명(확장자 제외)을 상품코드로 + 매칭합니다. 예: ABC-001.jpg → 상품코드 ABC-001 + + + {/* 파일 선택 존 */} +
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' + }`} + > + { + if (e.target.files) handleFiles(e.target.files); + e.target.value = ''; // 같은 파일 재선택 허용 + }} + /> + {matching ? ( + + 상품 목록과 매칭 중… + + ) : ( + <> + + + 이미지를 끌어다 놓거나 클릭해서 선택 (여러 장 가능) + + + )} +
+ + {/* 옵션 + 매칭표 */} + {rows.length > 0 && ( + <> + + +
+
+ 파일명 + 매칭 상품 + 상태 +
+
+ {rows.map((r, i) => ( +
+ + {r.file.name} + + + {r.product ? r.product.name : '—'} + + {renderStatus(r, overwrite)} +
+ ))} +
+
+ + {/* 요약 + 실행 */} +
+ + 업로드 대상 {uploadTargets.length}건 + {skippedByImage.length > 0 && ` · 기존 이미지로 건너뜀 ${skippedByImage.length}건`} + {rows.filter((r) => r.issue).length > 0 && ` · 제외 ${rows.filter((r) => r.issue).length}건`} + +
+ + {phase !== 'done' && ( + + )} +
+
+ + )} +
+
+ ); +} + +// 파일 → 상품 매칭표. 코드 비교는 공백 제거 + 대소문자 무시(엑셀·수기 등록 코드 표기가 섞여 있어서). +function buildRows( + files: File[], + products: Product[], + myUserId: string | undefined, + isSuperAdmin: boolean, +): MatchRow[] { + const byCode = new Map(); + 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(); + 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>, + file: File, + patch: Partial>, +) { + setRows((prev) => prev.map((r) => (r.file === file ? { ...r, ...patch } : r))); +} + +const ISSUE_LABEL: Record = { + unmatched: '코드 미매칭', + dup_code: '코드 중복', + dup_file: '파일명 중복', + forbidden: '권한 없음', +}; + +function renderStatus(row: MatchRow, overwrite: boolean) { + if (row.issue) { + return ( + + {ISSUE_LABEL[row.issue]} + + ); + } + if (row.state === 'uploading') { + return ( + + 업로드 중 + + ); + } + if (row.state === 'done') { + return ( + + 완료 + + ); + } + if (row.state === 'failed') { + return ( + + 실패 + + ); + } + if (!overwrite && row.hasImage) { + return ( + + 이미지 있음 · 건너뜀 + + ); + } + return ( + + {row.hasImage ? '덮어쓰기' : '업로드 대기'} + + ); +} diff --git a/negodata/front/src/features/products/components/PriceUpdateModal.tsx b/negodata/front/src/features/products/components/PriceUpdateModal.tsx index 21a18e1..79ee979 100644 --- a/negodata/front/src/features/products/components/PriceUpdateModal.tsx +++ b/negodata/front/src/features/products/components/PriceUpdateModal.tsx @@ -9,6 +9,7 @@ import { Typography } from '@/components/ui/typography'; import { useScrollLock } from '@/lib/useScrollLock'; import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item'; import type { LowestPriceEntryByMall } from '@/api/generated/model/lowestPriceEntryByMall'; +import type { LowestPriceEntrySources } from '@/api/generated/model/lowestPriceEntrySources'; import type { Product } from '../types'; type PriceUpdateModalProps = { @@ -35,9 +36,24 @@ const SOURCES = [ { key: 'coupang', label: '쿠팡' }, ] as const; -// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. 값이 없으면 그 몰은 못 찾은 것. +// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. type MallPrices = Record; +// 값이 없는 몰은 두 경우다 — '거기엔 없었다'와 '거기를 못 봤다'. 뜻이 정반대라 같이 보이면 안 된다. +// 사용자가 할 수 있는 건 '쓴다 / 다시 시도 / 넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면 +// 같은 표기로 접는다(운영자 화면 lps-admin 은 7상태를 그대로 본다). +// matched → 가격 +// no_match · empty → '–' (결론은 '이 몰엔 없다') +// blocked · env_blocked · unavailable → '확인 못함' (행동은 '나중에 다시' 하나뿐) +// 상태 정의는 lps/docs/result-states.md. +const CONFIRMED_STATES = new Set(['matched', 'no_match', 'empty', 'skipped']); + +/** 그 몰을 확인하지 못했는가(=가격 자리에 '–' 대신 '확인 못함'을 써야 하는가). */ +const isUnconfirmed = (sources: LowestPriceEntrySources | undefined, source: string): boolean => { + const state = (sources as Record | null | undefined)?.[source]?.state; + return !!state && !CONFIRMED_STATES.has(state); +}; + const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => { const out: MallPrices = {}; for (const entry of byMall ?? []) { @@ -52,8 +68,8 @@ const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => { // 상품 1건의 처리 상태(화면 전체 상태와 구분해 ItemState 로 둔다). type ItemState = | { kind: 'pending' } // 접수됨 — 결과 대기 - | { kind: 'done'; price: number; malls: MallPrices } - | { kind: 'notfound'; malls: MallPrices } // 검색은 됐으나 같은 상품이 없었음(기존 값 유지) + | { kind: 'done'; price: number; malls: MallPrices; unconfirmed?: string[] } + | { kind: 'notfound'; malls: MallPrices; unconfirmed?: string[] } // 검색은 됐으나 같은 상품이 없었음(기존 값 유지) | { kind: 'failed'; reason?: string } // 접수 실패 또는 검색 실패(사이트 차단) — 재시도 대상 | { kind: 'timeout' } // 폴링 상한 초과 — 서버는 계속 검색 중 | { kind: 'stopped' }; // 사용자가 지켜보기를 중단 — 서버는 계속 검색 중 @@ -249,13 +265,17 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose if (!fresh) continue; pending.delete(id); const malls = toMallPrices(fresh.by_mall ?? null); + // 가격 자리에 '–'(없음) 대신 '확인 못함'을 써야 하는 몰들 + const unconfirmed = SOURCES.map((x) => x.key).filter((m) => isUnconfirmed(fresh.sources, m)); if (fresh.success_yn && fresh.lp_price != null) { found += 1; const prev = prevPriceOf(id); - if (prev <= 0 || fresh.lp_price < prev) succeeded.add(id); // 값이 실제로 갱신된 행 + // 값이 실제로 갱신된 행. 단 **못 본 몰이 있으면 완료로 치지 않는다** — + // 그 몰에 더 싼 값이 있었을 수 있어, 다음 '다시 검색'의 기본 대상으로 남겨둔다. + if ((prev <= 0 || fresh.lp_price < prev) && unconfirmed.length === 0) succeeded.add(id); // by_mall 이 비어 오는 옛 이력 대비 — 최소한 대표 최저가는 보이도록 폴백을 채운다. if (Object.keys(malls).length === 0) malls.etc = fresh.lp_price; - setItem(id, { kind: 'done', price: fresh.lp_price, malls }); + setItem(id, { kind: 'done', price: fresh.lp_price, malls, unconfirmed }); } else if (fresh.fail_reason === 'error') { // 검색 자체가 실패한 경우(모든 소스 차단). '못 찾음'과 뜻이 완전히 다르다 — // 상품이 없다는 게 아니라 **확인을 못 했다**는 뜻이라, 재시도 대상으로 분류한다. @@ -264,7 +284,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose // 문구는 '탐색 실패 사유: {reason}' 형태로 붙는다 — 사유만 간결하게 담는다. setItem(id, { kind: 'failed', reason: '사이트 차단으로 가격을 확인하지 못했습니다 (잠시 후 다시 시도해 주세요)' }); } else { - setItem(id, { kind: 'notfound', malls }); + setItem(id, { kind: 'notfound', malls, unconfirmed }); } } catch { /* 일시 오류는 다음 tick 재시도 */ @@ -311,7 +331,13 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose const prices = Object.values(malls); const best = prices.length > 0 ? Math.min(...prices) : null; const price = malls[source]; - if (price === undefined) return –; + if (price === undefined) { + // 안 본 걸 '없음(–)'으로 보여주면 사용자는 '이 몰엔 더 싼 게 없다'로 읽는다 — 사실이 아니다. + const unseen = (s.kind === 'done' || s.kind === 'notfound') && s.unconfirmed?.includes(source); + return unseen + ? 확인 못함 + : –; + } const isBest = price === best; return ( @@ -330,6 +356,16 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose switch (s.kind) { case 'done': case 'notfound': + // 못 본 몰이 있으면 '변동 없음'이라 말하면 안 된다 — 그 몰에 더 싼 값이 있었을 수 있어 + // 이 결과는 최종이 아니다. 사용자에게 필요한 건 어느 몰이 왜 막혔는지가 아니라 + // '결과가 완전하지 않다'는 사실 하나다(재시도할 이유가 생긴다). + if (s.unconfirmed?.length) { + return ( + + 일부 확인 못함 + + ); + } // 검색은 정상 수행됐으나 기존보다 낮은 가격이 없었음(미발견 포함) — 값이 안 바뀐 상태. return 변동 없음; case 'failed': diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 3742d3e..ad4001f 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -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({
)} + {/* 전체 통일 회사는 가격 입력 기준을 폼에서 못 박는다 — 상품별 VAT 입력이 없어 달리 알 길이 없다. */} + {vatUnified && ( + + 모든 가격은 VAT 별도(제외) 기준으로 입력합니다. + + )}
{/* Price */}
diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index c0cb526..89460d2 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -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'), diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index d42ddcf..7b23ecf 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -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(); (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 별도(제외) 기준입니다.'} {!targetReady && ( diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index 2b036ea..e1b3457 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -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" > @@ -243,7 +243,7 @@ function BotBubble({ )} {m.script && !usedCard && ( - {renderEmphasis(maskPrices(m.script))} + {renderEmphasis(m.script)} )} - {maskPrices(m.script)} + {m.script} )} ) : usedCard.script ? ( - {renderCardScriptPreview(maskPrices(usedCard.script))} + {renderCardScriptPreview(usedCard.script)} ) : null} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx index dcba4f1..66cd5df 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/SessionsStatusTab.tsx @@ -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" > - + {sendingAll ? : } {sendingAll ? '발송 중…' : `초청 메일 발송${unsentCount > 0 ? ` (${unsentCount})` : ''}`}
@@ -296,10 +296,10 @@ export function SessionsStatusTab({ 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" > - + {sendingId === sess.session_id ? : } } /> @@ -429,14 +429,20 @@ export function SessionsStatusTab({ )}
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx index 9d81ec5..51b8d96 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -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} - 배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {vatYn ? 'VAT포함' : 'VAT별도'} + 배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {!vatUnified && vatYn ? 'VAT포함' : 'VAT별도'} {isLoading || !bd ? ( diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index 2428ede..59e9626 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -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) => ( {est.createdDate ?? '-'} ), diff --git a/negodata/front/src/features/settings/SettingsView.tsx b/negodata/front/src/features/settings/SettingsView.tsx index eff5eb9..51e59ef 100644 --- a/negodata/front/src/features/settings/SettingsView.tsx +++ b/negodata/front/src/features/settings/SettingsView.tsx @@ -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 = { 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
@@ -229,16 +239,32 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting {draft.branding?.logo_url ? ( 로고 미리보기 ) : ( - + )} {draft.branding?.service_name || 'NegoData'}
+ + +
+
+ +