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/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..1654294 100644
--- a/frontend/src/apis/auth/auth.type.ts
+++ b/frontend/src/apis/auth/auth.type.ts
@@ -58,7 +58,6 @@ export interface Branding {
service_name?: string
logo_url?: string
primary_color?: string
- email_header?: string
helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
}
diff --git a/frontend/src/features/auth/hooks/usePreLoginBranding.ts b/frontend/src/features/auth/hooks/usePreLoginBranding.ts
index cc37cf9..932735a 100644
--- a/frontend/src/features/auth/hooks/usePreLoginBranding.ts
+++ b/frontend/src/features/auth/hooks/usePreLoginBranding.ts
@@ -46,7 +46,7 @@ export function usePreLoginBranding(): Branding | null {
primary_color: res.primary_color || undefined,
helpdesk: res.helpdesk?.length ? res.helpdesk : undefined,
}
- if (!next.service_name && !next.logo_url && !next.helpdesk) return
+ if (!next.service_name && !next.logo_url && !next.primary_color && !next.helpdesk) return
setBranding(next)
writeCached(next) // 다음 진입에 session_id 가 없어도 이 회사로 보이게 한다
})
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..4809727 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -1,7 +1,7 @@
import { type ReactNode } from 'react'
import { Logo } from '@/components'
import { useMeQuery } from '@/apis'
-import { cn } from '@/lib'
+import { cn, useBrandColor } from '@/lib'
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
const SIDEBAR_WIDTH = {
@@ -44,7 +44,8 @@ export function MainLayout({
header,
children,
}: MainLayoutProps) {
- const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
+ const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고/브랜드 색) 주입
+ useBrandColor(user?.branding?.primary_color)
const panes = (
<>