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