Merge branch 'feat/source-state'

'못 봤다'를 '없다'고 말하던 문제를 4단계로 해결한다. 상태를 하나로 정의·저장하고 표시 단계에서
보는 사람에 맞게 접는다 — 운영자(lps-admin)는 7상태 그대로, 실사용자(negodata)는 셋으로.

1) SourceState 7상태 — 어댑터가 이미 알던 '봤다/못 봤다' 구분을 핸들러가 버리지 않게 한다
2) price_history.sources/partial — 그 구분을 담을 자리(by_mall 은 가격 있는 몰만 담는다)
3) lps-admin — 몰별 상태·차단 마커·조치 힌트(blocked=자동회복 vs env_blocked=사람이 고쳐야)
4) negodata — '–'(확인했고 없음)와 '확인 못함'(못 봄)을 가르고, 후자는 재검색 대상으로 남긴다

⚠️ 배포 순서: 마이그레이션 2개(6_lps_2026-08_dbeaver.sql · 2026-08-07-iilp-source-state.sql)를
   **코드보다 먼저** 적용해야 한다. negodata 가 ORM 전체 엔티티를 조회하므로 컬럼이 없으면
   최저가 조회가 통째로 실패한다(재현 확인). 역순(마이그레이션 후 옛 코드)은 안전하다.

실환경 검증: 실제 크롤 결과로 네이버 no_match→'–', 쿠팡 env_blocked→'확인 못함' 확인.
잡 DEAD 0건, 네거티브 캐시 미오염, 포트 소각 8/100 에서 차단.
This commit is contained in:
민헌 2026-08-07 14:46:02 +09:00
commit 0c6c431f0a
92 changed files with 2879 additions and 571 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ CLAUDE.md
/mobile.mov
.gstack/
.playwright-mcp/

BIN
0729~30_테스트.xlsx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@ -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 와 같아야 표기가 갈리지 않는다)

View File

@ -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 # 협상카드 사용 횟수 상한 로드 확인

View File

@ -72,5 +72,4 @@ class Res_HidePopup(Res_WebPacketProtocol):
class Res_SessionBranding(Res_WebPacketProtocol):
service_name: str = Field("", description="회사 서비스명(companies.settings.branding.service_name). 미설정 시 빈 값")
logo_url: str = Field("", description="회사 로고 URL")
primary_color: str = Field("", description="브랜드 색상(hex)")
helpdesk: list = Field(default_factory=list, description="헬프데스크 연락처 줄 목록(companies.settings.branding.helpdesk). 한 줄 = 담당자 한 명")

View File

@ -279,7 +279,6 @@ class AuthService:
branding = branding or {}
res.service_name = branding.get("service_name") or ""
res.logo_url = branding.get("logo_url") or ""
res.primary_color = branding.get("primary_color") or ""
res.helpdesk = branding.get("helpdesk") or []
return res

View File

@ -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"):
# 미설정 회사 폴백 — 공급가를 감췄으면 그 회사는 공급가를 관리하지 않는다는 뜻.

View File

@ -226,6 +226,35 @@ async def test_chat_init_forbidden_other_supplier(client, chat_seed):
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
async def test_chat_init_vat_mode_unified_shows_excluded(client, chat_seed, db_engine):
"""검증: 부가세 전체 통일 회사(features.vat_mode=unified_excluded)의 세션 채팅 init.
기대결과: 상품에 vat_yn=true 잔존값이 있어도 item_vat_yn=False 프론트가 'VAT별도' 고정 표기."""
import json
company_id = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO company.companies (company_id, name, status, settings) VALUES (:c, :n, 1, CAST(:s AS JSONB))"),
{"c": company_id, "n": f"{MARK}VAT통일사", "s": json.dumps({"features": {"vat_mode": "unified_excluded"}})},
)
await conn.execute(
text("UPDATE partner.suppliers SET company_id = :c WHERE supplier_id = :sid"),
{"c": company_id, "sid": chat_seed["supplier_id"]},
)
await conn.execute(
text("UPDATE partner.items SET vat_yn = true WHERE item_id = (SELECT item_id FROM negotiation.sessions WHERE session_id = :s)"),
{"s": chat_seed["sids"]["P"]},
)
try:
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert body["item_vat_yn"] is False
finally:
async with db_engine.begin() as conn:
await conn.execute(text("DELETE FROM company.companies WHERE company_id = :c"), {"c": company_id})
# ---- messages (오프닝 seed) -------------------------------------------------
async def test_messages_seeds_opening(client, chat_seed):
token = await _login_token(client)

BIN
dev-settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

View File

@ -0,0 +1,157 @@
import AppKit
import CoreGraphics
import Foundation
let output = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "docs/AIO2O-요청사항-반영보고서.pdf"
let W: CGFloat = 595, H: CGFloat = 842, M: CGFloat = 42
let navy = NSColor(calibratedRed: 0.06, green: 0.08, blue: 0.16, alpha: 1)
let ink = NSColor(calibratedRed: 0.11, green: 0.13, blue: 0.18, alpha: 1)
let muted = NSColor(calibratedRed: 0.39, green: 0.43, blue: 0.50, alpha: 1)
let paper = NSColor(calibratedRed: 0.98, green: 0.985, blue: 0.995, alpha: 1)
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
let purple = NSColor(calibratedRed: 0.48, green: 0.25, blue: 0.92, alpha: 1)
let green = NSColor(calibratedRed: 0.08, green: 0.60, blue: 0.37, alpha: 1)
let orange = NSColor(calibratedRed: 0.94, green: 0.48, blue: 0.10, alpha: 1)
let red = NSColor(calibratedRed: 0.85, green: 0.24, blue: 0.28, alpha: 1)
let blue = NSColor(calibratedRed: 0.13, green: 0.39, blue: 0.92, alpha: 1)
func pr(_ r: CGRect) -> CGRect { CGRect(x: r.minX, y: H-r.maxY, width: r.width, height: r.height) }
func font(_ s: CGFloat, _ w: NSFont.Weight = .regular) -> NSFont {
NSFont(name: "Apple SD Gothic Neo", size: s) ?? .systemFont(ofSize: s, weight: w)
}
func style(_ s: CGFloat, _ c: NSColor = ink, _ w: NSFont.Weight = .regular,
_ a: NSTextAlignment = .left, _ spacing: CGFloat = 2.5) -> [NSAttributedString.Key:Any] {
let p = NSMutableParagraphStyle(); p.alignment = a; p.lineSpacing = spacing; p.lineBreakMode = .byWordWrapping
return [.font:font(s,w), .foregroundColor:c, .paragraphStyle:p]
}
func text(_ t:String,_ r:CGRect,_ s:CGFloat=10,_ c:NSColor=ink,_ w:NSFont.Weight = .regular,
_ a:NSTextAlignment = .left,_ spacing:CGFloat=2.5) {
NSAttributedString(string:t,attributes:style(s,c,w,a,spacing)).draw(with:pr(r),options:[.usesLineFragmentOrigin,.usesFontLeading])
}
func box(_ r:CGRect,_ fill:NSColor = .white,_ stroke:NSColor? = line,_ radius:CGFloat=10) {
let p=NSBezierPath(roundedRect:pr(r),xRadius:radius,yRadius:radius); fill.setFill(); p.fill()
if let stroke { stroke.setStroke(); p.lineWidth=0.8; p.stroke() }
}
func pill(_ t:String,_ r:CGRect,_ c:NSColor) {
box(r,c.withAlphaComponent(0.12),nil,r.height/2)
text(t,CGRect(x:r.minX,y:r.minY+4,width:r.width,height:r.height-7),8.2,c,.semibold,.center,1)
}
func begin(_ ctx:CGContext,_ page:Int,_ title:String) {
ctx.beginPDFPage(nil); ctx.saveGState(); NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current=NSGraphicsContext(cgContext:ctx,flipped:false)
paper.setFill(); NSBezierPath(rect:pr(CGRect(x:0,y:0,width:W,height:H))).fill()
text(title,CGRect(x:M,y:30,width:420,height:16),7.5,muted,.medium)
text(String(format:"%02d",page),CGRect(x:W-M-30,y:30,width:30,height:16),8,muted,.medium,.right)
let p=NSBezierPath(); p.move(to:CGPoint(x:M,y:31)); p.line(to:CGPoint(x:W-M,y:31))
line.setStroke(); p.lineWidth=0.7; p.stroke()
}
func end(_ ctx:CGContext) {
NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); ctx.endPDFPage()
}
func heading(_ n:String,_ t:String,_ sub:String) {
pill(n,CGRect(x:M,y:54,width:34,height:24),purple)
text(t,CGRect(x:86,y:49,width:465,height:30),21,navy,.bold)
text(sub,CGRect(x:M,y:87,width:W-2*M,height:31),9.5,muted,.regular,.left,3)
}
func statusRow(_ no:String,_ title:String,_ body:String,_ status:String,_ c:NSColor,_ y:CGFloat,_ h:CGFloat=82) {
box(CGRect(x:M,y:y,width:W-2*M,height:h),.white,line,9)
pill(no,CGRect(x:M+12,y:y+13,width:28,height:20),c)
text(title,CGRect(x:M+50,y:y+12,width:338,height:19),10.5,navy,.bold)
pill(status,CGRect(x:W-M-102,y:y+12,width:90,height:21),c)
text(body,CGRect(x:M+50,y:y+37,width:W-2*M-64,height:h-44),8.8,ink,.regular,.left,2.4)
}
func metric(_ value:String,_ label:String,_ x:CGFloat,_ c:NSColor) {
box(CGRect(x:x,y:435,width:117,height:96),c.withAlphaComponent(0.08),c.withAlphaComponent(0.3),12)
text(value,CGRect(x:x+8,y:454,width:101,height:32),25,c,.bold,.center)
text(label,CGRect(x:x+8,y:493,width:101,height:20),9,muted,.medium,.center)
}
var media=CGRect(x:0,y:0,width:W,height:H)
guard let consumer=CGDataConsumer(url:URL(fileURLWithPath:output) as CFURL),
let ctx=CGContext(consumer:consumer,mediaBox:&media,nil) else { fatalError("PDF 생성 실패") }
// 1. cover
begin(ctx,1,"AIO2O · 요청사항 반영 보고서")
box(CGRect(x:0,y:0,width:W,height:H),navy,nil,0)
pill("IMPLEMENTATION REVIEW",CGRect(x:M,y:112,width:148,height:25),NSColor(calibratedRed:0.42,green:0.78,blue:1,alpha:1))
text("AIO2O 테스트 및 요청사항\n반영 결과 보고서",CGRect(x:M,y:166,width:510,height:112),34,.white,.bold,.left,7)
text("260727_AIO2O 테스트 및 요청사항.xlsx 기준\n현재 저장소 구현·커밋·검증 캡처 대조",CGRect(x:M,y:310,width:510,height:60),14,NSColor(calibratedWhite:0.78,alpha:1),.regular,.left,7)
box(CGRect(x:M,y:435,width:W-2*M,height:176),NSColor.white.withAlphaComponent(0.07),NSColor.white.withAlphaComponent(0.12),16)
text("결론",CGRect(x:M+22,y:458,width:460,height:25),13,.white,.bold)
text("핵심 업무 흐름은 대부분 구현되었습니다. 견적 목록·상세, 재협상 접수, 목표가 자동계산, 인터넷 최저가, 종료 의견, 결렬폼 통일, VAT 별도 표기, 10원 반올림은 코드 근거가 확인됩니다.\n\n다만 절충안/자동 제안가의 업무 적정성, SG명·유통레벨의 최종 UX, 최저가 VAT 산식은 추가 확인이 필요합니다.",CGRect(x:M+22,y:495,width:W-2*M-44,height:96),11,NSColor(calibratedWhite:0.88,alpha:1),.regular,.left,5)
text("작성일 2026.07.31 | 기준 브랜치 feature/negodata | HEAD 775984fe",CGRect(x:M,y:758,width:W-2*M,height:18),8.5,NSColor(calibratedWhite:0.60,alpha:1))
end(ctx)
// 2. summary
begin(ctx,2,"AIO2O · 요청사항 반영 보고서")
heading("01","종합 요약","엑셀 RAW 시트의 24개 요청을 현재 저장소 상태로 재판정했습니다. 중복 요청은 원 요청 번호를 유지했습니다.")
metric("18","완료·반영",M,green); metric("3","부분 반영",M+130,orange); metric("3","확인 필요",M+260,red); metric("24","전체 항목",M+390,blue)
text("판정 기준",CGRect(x:M,y:566,width:507,height:24),13,navy,.bold)
statusRow("A","완료·반영","사용자 화면과 처리 로직이 모두 확인되거나, 동일 기능을 제공하는 구현 및 검증 캡처가 존재합니다.","18건",green,603,60)
statusRow("B","부분 반영","핵심 기능은 있으나 요청한 명칭·선택값·산식 중 일부가 다르거나 배포/운영 확인이 남았습니다.","3건",orange,675,60)
statusRow("C","확인 필요","코드는 존재하지만 계산 결과의 업무 적정성을 확정할 수 없거나 요청 산식이 명시적으로 확인되지 않습니다.","3건",red,747,60)
end(ctx)
// 3. system 1
begin(ctx,3,"AIO2O · 요청사항 반영 보고서")
heading("02","기본 시스템 반영 내역","견적 생성부터 협력사·최저가·협상 화면까지의 공통 요청입니다.")
statusRow("01","견적관리 목록·상세/히스토리","견적 목록과 상세 드로어가 있으며, 상세의 채팅 탭·협상카드 탭이 세션 데이터를 연결합니다. 견적번호별 진행/완료 상태와 상세 확인 경로가 마련됐습니다.","완료",green,132)
statusRow("02","재협상 접수 및 관리","공급사 포털에서 결렬 건 재협상 요청·철회가 가능하고, 구매자 화면에 재협상 요청 목록·검토 시트·승인/반려 및 알림이 구현됐습니다.","완료",green,226)
statusRow("03","MD 제시가 용어·위치·판매가","MD는 ‘구매담당자’로 통일했고 제시가/산정후보를 3. 낙찰기준’으로 이동했습니다. 판매가 설정은 숨김 처리되어 요청 흐름과 일치합니다.","완료",green,320)
statusRow("04","목표가 자동 산출","매입가 × (1 목표 네고율)로 구매담당자 제시가를 자동 입력합니다. 예: 10,000원, 2% → 9,800원. 프론트 자동계산과 백엔드 가격 처리 근거가 있습니다.","완료",green,414)
statusRow("05","공급사/매입가 라벨 일원화","상품 및 견적 화면의 회사별 필드 라벨 설정을 연동해 ‘공급사=매입가’ 표기 정책을 적용할 수 있게 했습니다. 실제 운영 회사 설정값 확인은 필요합니다.","부분",orange,508)
statusRow("06","인터넷 최저가 수집","15%에서 멈추던 Worker/큐 처리 문제를 수정하고, 몰별 결과·진행 상태·이력 화면을 재설계했습니다. 다만 요청 산식 (상품가+배송비)/1.1’의 최종 대표값 적용은 코드에서 확정되지 않습니다.","부분",orange,602)
statusRow("07","신규 상품 공급사 입력","신규 상품 등록 폼에 공급사 선택기를 추가하고 상품–공급사 매핑을 저장하도록 구현했습니다.","완료",green,696)
end(ctx)
// 4. system 2
begin(ctx,4,"AIO2O · 요청사항 반영 보고서")
heading("03","협력사·협상 화면 반영","용어 통일, 종료 단계, 가격 표기와 협상 지표를 중심으로 확인했습니다.")
statusRow("08","협력사 SG명·유통레벨","취급상품 기반 분류와 공급유형 선택/저장은 구현되어 있습니다. 다만 요청한 SG명 콤보와 유통레벨 4종(제조·총판·대리점·일반유통), 취급상품 삭제가 그대로 완성됐는지는 추가 UX 확인이 필요합니다.","부분",orange,132)
statusRow("09","리드타임 → 표준납기","협상 완료 부가정보와 API 설명에 ‘표준납기’가 반영되고 회사 정의 session_fields와 연결됩니다.","완료",green,226)
statusRow("10","협상 단가 VAT 별도","협상 상품정보·요약·목록/상세의 단가 표기를 VAT 별도로 통일했습니다. 검증 캡처도 존재합니다.","완료",green,320)
statusRow("11","협상 성공률 기준 안내","성공률은 공급사 제시가를 앵커가·목표가와 비교한 1~99 지표입니다. 100% 미달이 결렬 조건은 아니며, 실제 종료는 별도 낙찰/개찰 규칙이 결정합니다.","완료(안내)",blue,414)
statusRow("12","협상 종료 추가 의견","타결 부가정보와 결렬 통합폼 모두 ‘기타 의견’을 받으며 sessions.custom.opinion에 저장합니다. 구매자 상세·요약에서 조회되고 완료 후 잠깁니다.","완료",green,508)
statusRow("13","결렬 사유·희망가격 통일","기존 RejectRSP/RejectCM을 단일 RejectForm으로 교체했습니다. 결렬사유·희망가·의견을 한 흐름에서 받고 reject_reason/reject_price에 저장합니다.","완료",green,602)
statusRow("14","카드 사용 횟수 제한","견적 설정의 card_count(기본 3)를 컨텍스트에서 읽어 실제 사용 가능한 카드 수와 종료 조건을 제한하도록 반영했습니다. 운영 시 기존 세션 회귀검증을 권장합니다.","완료",green,696)
end(ctx)
// 5. case-specific
begin(ctx,5,"AIO2O · 요청사항 반영 보고서")
heading("04","견적번호별 이슈 반영","EST-202607-05DE·8945·C9D2 사례에서 제기된 가격/종료 흐름을 대조했습니다.")
statusRow("15","앵커·자동 제안가 10원 반올림","앵커 생성가, 목표가 후보, 협상카드 카운터를 공통으로 10원 단위 반올림합니다. 예: 15,213원 → 15,210원. 관련 커밋과 단위 테스트가 있습니다.","완료",green,132)
statusRow("16","절충안 계산식","협상카드 전술에는 앵커·목표가·직전 제시가를 이용한 중간값 및 목표가 상한 로직이 존재합니다. 다만 ‘절충안’의 기대 공식이 엑셀에 없어 업무적으로 맞는지 확정할 수 없습니다.","확인 필요",red,226)
statusRow("17","05DE/C9D2 결렬 희망가격","견적 유형별로 갈리던 결렬 화면을 단일 폼으로 통합해 희망가격 입력 절차를 동일하게 만들었습니다.","완료",green,320)
statusRow("18","8945 협상 마무리 개편","종료 후 표준납기·MOQ·발주배수·배송유형 등 회사 정의 부가정보를 선택/입력하고, 기타 의견과 함께 최종 요약에 반영합니다. 배송 선택값은 회사 설정에 따라 구성됩니다.","완료",green,414)
statusRow("19","C9D2 자동 제안가 갭","제안가는 카드 전술과 앵커·목표가·직전 제시가의 조합으로 계산되고 10원 반올림됩니다. 17,500원→15,210원의 10.5% 갭이 정책상 적정한지는 목표/앵커 설정을 포함한 별도 검증이 필요합니다.","확인 필요",red,508)
statusRow("20","성공/실패 후 의견 조회","협력사가 입력한 종료 의견은 공급사 요약과 구매자 견적 상세 양쪽에서 확인할 수 있고, 종료 후 읽기 전용으로 잠깁니다.","완료",green,602)
statusRow("21","중복 요청 통합 반영","엑셀 18/22(성공률), 19/25(종료 의견), 5/23(목표가), 14/17/20(결렬폼)은 각각 하나의 공통 구현으로 해소했습니다.","완료",green,696)
end(ctx)
// 6. evidence
begin(ctx,6,"AIO2O · 요청사항 반영 보고서")
heading("05","구현 근거","최근 커밋과 현재 코드에서 확인한 핵심 근거입니다. 커밋 단위로 기능 범위를 추적할 수 있습니다.")
statusRow("A","b33ae05c · 견적 가격/상품/라벨","목표가 자동입력, 산정후보 위치 이동, 앵커·후보 10원 반올림, 신규 상품 공급사 입력, 회사 설정 라벨 연동.","커밋",purple,132,72)
statusRow("B","a56589c6 · 종료폼/의견/VAT","결렬폼 통합, 희망가·사유 저장, 타결/결렬 의견 수취, 상품정보 라벨 연동, 협상 단가 VAT 별도 표기.","커밋",purple,216,72)
statusRow("C","30f13483 · 인터넷 최저가","무한 로딩 버그 수정, Worker 설정 보강, 몰별 최저가·진행/상세 UI 및 이력 저장 개선.","커밋",purple,300,72)
statusRow("D","9dca78dc · 완료 부가정보","완료 부가정보 수취·요약 표시·잠금, 구매자 상세 노출, VAT 표기 통일.","커밋",purple,384,72)
statusRow("E","2a004734 · 견적 상세 연결","견적 상세의 채팅·협상카드 탭 연동과 드로어 탐색 개선.","커밋",purple,468,72)
statusRow("F","775984fe / f554202c · 반올림","협상카드 카운터와 자동 앵커를 10원 단위 반올림으로 통일.","커밋",purple,552,72)
statusRow("G","화면 검증 캡처","목록/상세, 종료 의견, VAT, 완료 요약, 읽기 전용 잠금 등 12개 캡처가 저장소 루트에 남아 있습니다.","캡처",blue,636,72)
text("주의: 본 보고서는 2026-07-31 현재 로컬 저장소의 코드·커밋·캡처를 기준으로 합니다. 운영 배포 여부와 기존 데이터 마이그레이션 상태는 별도 확인 대상입니다.",CGRect(x:M,y:742,width:W-2*M,height:42),8.8,muted,.regular,.left,3)
end(ctx)
// 7. actions
begin(ctx,7,"AIO2O · 요청사항 반영 보고서")
heading("06","남은 확인 및 권고","기능 누락이라기보다 업무 규칙·운영 설정을 확정해야 하는 항목입니다.")
statusRow("1","최저가 VAT 대표값 확정","현재 LPS는 상품가와 배송비를 별도 수집·표시합니다. 대표 최저가를 반드시 (상품가+배송비)/1.1로 저장할지, 화면 표시만 할지 정책을 확정한 뒤 테스트를 추가해야 합니다.","우선순위 높음",red,142,98)
statusRow("2","절충안/자동 제안가 기준 검증","05DE·C9D2의 실제 앵커가·목표가·직전 제시가를 넣어 계산 결과를 재현하고, 허용 최대 인하폭 또는 목표가 클램프 기준을 업무 담당자와 합의하는 것이 좋습니다.","우선순위 높음",red,254,98)
statusRow("3","SG명·유통레벨 UX 확정","현행 취급상품 기반 분류/공급유형을 요청한 SG 콤보와 유통레벨 4종으로 대체할지, 데이터 모델을 유지한 채 라벨만 조정할지 결정이 필요합니다.","우선순위 중간",orange,366,98)
statusRow("4","운영 배포·기존 세션 회귀검증","종료폼, 의견, 카드 횟수 제한은 신규 코드에 반영됐습니다. 운영 컨테이너 재빌드 후 기존 세션과 신규 세션에서 각각 1회 이상 확인해야 합니다.","배포 확인",blue,478,98)
box(CGRect(x:M,y:612,width:W-2*M,height:118),purple.withAlphaComponent(0.08),purple.withAlphaComponent(0.28),12)
text("권장 최종 승인 기준",CGRect(x:M+18,y:630,width:470,height:22),12,purple,.bold)
text("① 운영 배포 버전 확인 ② 대표 견적 3건 시나리오 재실행 ③ 계산식 2건 서면 확정\n④ SG/유통레벨 화면 승인 ⑤ 완료·결렬 의견이 구매자 상세에 저장되는지 확인",CGRect(x:M+18,y:662,width:470,height:50),10,ink,.medium,.left,5)
text("— End of report —",CGRect(x:M,y:760,width:W-2*M,height:20),8,muted,.medium,.center)
end(ctx)
ctx.closePDF()

Binary file not shown.

View File

@ -0,0 +1,790 @@
import AppKit
import CoreGraphics
import Foundation
let outPath = CommandLine.arguments.count > 1
? CommandLine.arguments[1]
: "docs/backend-advanced-concepts-ko.pdf"
let W: CGFloat = 595
let H: CGFloat = 842
let margin: CGFloat = 44
let navy = NSColor(calibratedRed: 0.055, green: 0.086, blue: 0.16, alpha: 1)
let ink = NSColor(calibratedRed: 0.10, green: 0.13, blue: 0.18, alpha: 1)
let muted = NSColor(calibratedRed: 0.37, green: 0.42, blue: 0.50, alpha: 1)
let paper = NSColor(calibratedRed: 0.975, green: 0.98, blue: 0.99, alpha: 1)
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
let blue = NSColor(calibratedRed: 0.16, green: 0.39, blue: 0.93, alpha: 1)
let cyan = NSColor(calibratedRed: 0.10, green: 0.69, blue: 0.74, alpha: 1)
let green = NSColor(calibratedRed: 0.10, green: 0.63, blue: 0.39, alpha: 1)
let orange = NSColor(calibratedRed: 0.94, green: 0.47, blue: 0.12, alpha: 1)
let red = NSColor(calibratedRed: 0.88, green: 0.25, blue: 0.28, alpha: 1)
let purple = NSColor(calibratedRed: 0.48, green: 0.32, blue: 0.89, alpha: 1)
func pdfRect(_ r: CGRect) -> CGRect {
CGRect(x: r.minX, y: H - r.maxY, width: r.width, height: r.height)
}
func pdfPoint(_ p: CGPoint) -> CGPoint {
CGPoint(x: p.x, y: H - p.y)
}
func font(_ size: CGFloat, _ weight: NSFont.Weight = .regular) -> NSFont {
NSFont(name: "Apple SD Gothic Neo", size: size)
?? NSFont.systemFont(ofSize: size, weight: weight)
}
func mono(_ size: CGFloat) -> NSFont {
NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
func attrs(_ size: CGFloat, color: NSColor = ink, weight: NSFont.Weight = .regular,
align: NSTextAlignment = .left, lineSpacing: CGFloat = 3) -> [NSAttributedString.Key: Any] {
let p = NSMutableParagraphStyle()
p.alignment = align
p.lineSpacing = lineSpacing
p.lineBreakMode = .byWordWrapping
return [.font: font(size, weight), .foregroundColor: color, .paragraphStyle: p]
}
func drawText(_ text: String, _ rect: CGRect, size: CGFloat = 11, color: NSColor = ink,
weight: NSFont.Weight = .regular, align: NSTextAlignment = .left,
lineSpacing: CGFloat = 3) {
NSAttributedString(string: text, attributes: attrs(size, color: color, weight: weight,
align: align, lineSpacing: lineSpacing))
.draw(with: pdfRect(rect), options: [.usesLineFragmentOrigin, .usesFontLeading])
}
func rounded(_ rect: CGRect, radius: CGFloat = 12, fill: NSColor = .white,
stroke: NSColor? = line, width: CGFloat = 1) {
let p = NSBezierPath(roundedRect: pdfRect(rect), xRadius: radius, yRadius: radius)
fill.setFill(); p.fill()
if let stroke { stroke.setStroke(); p.lineWidth = width; p.stroke() }
}
func pill(_ text: String, x: CGFloat, y: CGFloat, w: CGFloat, color: NSColor) {
rounded(CGRect(x: x, y: y, width: w, height: 25), radius: 12.5,
fill: color.withAlphaComponent(0.12), stroke: nil)
drawText(text, CGRect(x: x, y: y + 5, width: w, height: 16), size: 9.5,
color: color, weight: .semibold, align: .center)
}
func arrow(_ from: CGPoint, _ to: CGPoint, color: NSColor = muted) {
let from = pdfPoint(from), to = pdfPoint(to)
let p = NSBezierPath(); p.move(to: from); p.line(to: to)
color.setStroke(); p.lineWidth = 1.8; p.stroke()
let a = atan2(to.y - from.y, to.x - from.x)
let l: CGFloat = 7
let h = NSBezierPath()
h.move(to: to)
h.line(to: CGPoint(x: to.x - l * cos(a - .pi / 6), y: to.y - l * sin(a - .pi / 6)))
h.line(to: CGPoint(x: to.x - l * cos(a + .pi / 6), y: to.y - l * sin(a + .pi / 6)))
h.close(); color.setFill(); h.fill()
}
func node(_ title: String, _ sub: String, rect: CGRect, color: NSColor) {
rounded(rect, radius: 10, fill: color.withAlphaComponent(0.10),
stroke: color.withAlphaComponent(0.55), width: 1.2)
drawText(title, CGRect(x: rect.minX + 8, y: rect.minY + 10, width: rect.width - 16, height: 18),
size: 10.5, color: color, weight: .bold, align: .center)
drawText(sub, CGRect(x: rect.minX + 8, y: rect.minY + 31, width: rect.width - 16, height: rect.height - 36),
size: 8.5, color: muted, align: .center, lineSpacing: 1)
}
func sectionTitle(_ number: String, _ title: String, _ subtitle: String, color: NSColor) {
pill(number, x: margin, y: 48, w: 34, color: color)
drawText(title, CGRect(x: 86, y: 46, width: 450, height: 30), size: 22,
color: navy, weight: .bold)
drawText(subtitle, CGRect(x: margin, y: 82, width: W - 2 * margin, height: 26),
size: 10.5, color: muted)
}
func footer(_ page: Int, _ label: String = "O2O Negosium · Backend Concepts") {
let p = NSBezierPath()
p.move(to: CGPoint(x: margin, y: H - 34)); p.line(to: CGPoint(x: W - margin, y: H - 34))
line.setStroke(); p.lineWidth = 0.7; p.stroke()
drawText(label, CGRect(x: margin, y: H - 28, width: 350, height: 14), size: 7.5, color: muted)
drawText("\(page)", CGRect(x: W - margin - 35, y: H - 28, width: 35, height: 14),
size: 8, color: muted, align: .right)
}
func callout(_ title: String, _ body: String, rect: CGRect, color: NSColor) {
rounded(rect, radius: 12, fill: color.withAlphaComponent(0.08),
stroke: color.withAlphaComponent(0.35))
rounded(CGRect(x: rect.minX, y: rect.minY, width: 5, height: rect.height),
radius: 2.5, fill: color, stroke: nil)
drawText(title, CGRect(x: rect.minX + 16, y: rect.minY + 12,
width: rect.width - 28, height: 20),
size: 11, color: color, weight: .bold)
drawText(body, CGRect(x: rect.minX + 16, y: rect.minY + 37,
width: rect.width - 28, height: rect.height - 45),
size: 9.5, color: ink, lineSpacing: 3)
}
func comparison(_ leftTitle: String, _ left: String, _ rightTitle: String, _ right: String,
y: CGFloat, color: NSColor) {
let gap: CGFloat = 14
let cw = (W - 2 * margin - gap) / 2
callout(leftTitle, left, rect: CGRect(x: margin, y: y, width: cw, height: 126), color: red)
callout(rightTitle, right, rect: CGRect(x: margin + cw + gap, y: y, width: cw, height: 126), color: color)
}
func codeBox(_ title: String, _ path: String, _ code: String, rect: CGRect, accent: NSColor) {
rounded(rect, radius: 10, fill: navy, stroke: nil)
drawText(title, CGRect(x: rect.minX + 14, y: rect.minY + 11,
width: rect.width - 28, height: 17),
size: 10, color: .white, weight: .bold)
drawText(path, CGRect(x: rect.minX + 14, y: rect.minY + 30,
width: rect.width - 28, height: 14),
size: 7.5, color: accent)
let p = NSMutableParagraphStyle(); p.lineSpacing = 2; p.lineBreakMode = .byClipping
NSAttributedString(string: code, attributes: [.font: mono(7.8), .foregroundColor: NSColor(calibratedWhite: 0.88, alpha: 1), .paragraphStyle: p])
.draw(with: pdfRect(CGRect(x: rect.minX + 14, y: rect.minY + 51,
width: rect.width - 28, height: rect.height - 60)),
options: [.usesLineFragmentOrigin])
}
func beginPage(_ ctx: CGContext, page: Int, label: String = "O2O Negosium · Backend Concepts") {
ctx.beginPDFPage(nil)
ctx.saveGState()
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false)
paper.setFill(); NSBezierPath(rect: pdfRect(CGRect(x: 0, y: 0, width: W, height: H))).fill()
footer(page, label)
}
func endPage(_ ctx: CGContext) {
NSGraphicsContext.restoreGraphicsState()
ctx.restoreGState()
ctx.endPDFPage()
}
var mediaBox = CGRect(x: 0, y: 0, width: W, height: H)
guard let consumer = CGDataConsumer(url: URL(fileURLWithPath: outPath) as CFURL),
let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
fatalError("PDF context 생성 실패")
}
// 1 Cover
beginPage(ctx, page: 1, label: "O2O Negosium · Backend Field Guide")
rounded(CGRect(x: 0, y: 0, width: W, height: H), radius: 0, fill: navy, stroke: nil)
for i in 0..<7 {
let x = CGFloat(50 + i * 78)
let c = [blue, cyan, green, orange, purple][i % 5]
rounded(CGRect(x: x, y: 85 + CGFloat((i % 3) * 28), width: 48, height: 48),
radius: 24, fill: c.withAlphaComponent(0.35), stroke: nil)
}
drawText("BACKEND", CGRect(x: margin, y: 190, width: 507, height: 35), size: 15,
color: cyan, weight: .bold)
drawText("어려운 개념 5가지,\n코드로 이해하기", CGRect(x: margin, y: 228, width: 507, height: 118),
size: 36, color: .white, weight: .bold, lineSpacing: 7)
drawText("분산 시스템 · 트랜잭션/동시성 · 멀티테넌시\n캐시 정합성 · 스케줄러/배치",
CGRect(x: margin, y: 370, width: 507, height: 62), size: 15,
color: NSColor(calibratedWhite: 0.80, alpha: 1), lineSpacing: 8)
rounded(CGRect(x: margin, y: 485, width: 507, height: 154), radius: 18,
fill: NSColor.white.withAlphaComponent(0.07),
stroke: NSColor.white.withAlphaComponent(0.15))
drawText("이 문서는 이렇게 읽어요", CGRect(x: 66, y: 510, width: 455, height: 25),
size: 14, color: .white, weight: .bold)
drawText("① 일상 비유로 개념 잡기\n② 실제 서비스 흐름을 그림으로 보기\n③ 프로젝트 코드에서 구현 확인하기\n④ 없을 때 생기는 문제와 비교하기",
CGRect(x: 66, y: 548, width: 455, height: 78), size: 11.5,
color: NSColor(calibratedWhite: 0.88, alpha: 1), lineSpacing: 6)
drawText("Generated from the current repository · 2026-07-29",
CGRect(x: margin, y: 758, width: 507, height: 18), size: 8.5,
color: NSColor(calibratedWhite: 0.62, alpha: 1))
endPage(ctx)
// 2 Architecture map
beginPage(ctx, page: 2)
drawText("먼저, 서비스 지도를 봅시다", CGRect(x: margin, y: 48, width: 507, height: 34),
size: 24, color: navy, weight: .bold)
drawText("다섯 개념은 따로 노는 것이 아니라, 한 요청이 여러 서비스와 저장소를 지나면서 함께 작동합니다.",
CGRect(x: margin, y: 88, width: 507, height: 32), size: 10.5, color: muted)
node("사용자", "브라우저", rect: CGRect(x: 44, y: 170, width: 90, height: 62), color: purple)
node("Backend", "채팅·공급사", rect: CGRect(x: 184, y: 145, width: 102, height: 72), color: blue)
node("Agent", "AI 협상", rect: CGRect(x: 348, y: 145, width: 102, height: 72), color: orange)
node("Negodata", "견적·관리", rect: CGRect(x: 184, y: 270, width: 102, height: 72), color: cyan)
node("LPS", "최저가 Worker", rect: CGRect(x: 348, y: 270, width: 102, height: 72), color: green)
node("PostgreSQL", "업무 원본", rect: CGRect(x: 184, y: 405, width: 130, height: 72), color: purple)
node("Redis", "앵커링 캐시", rect: CGRect(x: 368, y: 405, width: 100, height: 72), color: red)
arrow(CGPoint(x: 134, y: 200), CGPoint(x: 184, y: 182), color: purple)
arrow(CGPoint(x: 286, y: 180), CGPoint(x: 348, y: 180), color: blue)
arrow(CGPoint(x: 235, y: 217), CGPoint(x: 235, y: 270), color: cyan)
arrow(CGPoint(x: 286, y: 304), CGPoint(x: 348, y: 304), color: green)
arrow(CGPoint(x: 235, y: 342), CGPoint(x: 235, y: 405), color: purple)
arrow(CGPoint(x: 399, y: 342), CGPoint(x: 415, y: 405), color: red)
callout("① 경계가 생기면 ‘분산 시스템’", "서비스 A가 서비스 B를 네트워크로 호출하는 순간, 지연·타임아웃·부분 실패를 다뤄야 합니다.",
rect: CGRect(x: margin, y: 525, width: 246, height: 105), color: blue)
callout("② 여러 실행자가 만나면 ‘동시성’", "사용자 클릭과 스케줄러가 같은 견적을 동시에 마감할 수 있어, DB가 최종 심판 역할을 합니다.",
rect: CGRect(x: 305, y: 525, width: 246, height: 105), color: orange)
callout("③ 빠르게 읽되 원본을 지키면 ‘캐시’", "Redis와 프로세스 메모리는 복사본입니다. PostgreSQL과 설정 파일이 원본입니다.",
rect: CGRect(x: margin, y: 650, width: 246, height: 105), color: red)
callout("④ 회사별 경계를 지키면 ‘멀티테넌시’", "요청 헤더에서 회사 ID를 결정하고, 회사별 설정·엔진을 선택합니다.",
rect: CGRect(x: 305, y: 650, width: 246, height: 105), color: purple)
endPage(ctx)
// 3 Distributed systems: concept first
beginPage(ctx, page: 3)
sectionTitle("3", "분산 시스템 — 개념부터", "여러 독립 실행 단위가 네트워크를 통해 하나의 업무를 완성하는 시스템", color: blue)
callout("정확한 정의", "프로세스·컨테이너·서버가 각자 메모리와 실행 상태를 가지고, HTTP나 메시지로 통신하는 구조입니다. 한 서비스의 함수 호출과 달리 상대의 상태를 직접 볼 수 없고, 네트워크 응답만으로 결과를 추론해야 합니다.",
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: blue)
drawText("왜 어려운가: 네트워크에는 네 가지 결과가 있습니다", CGRect(x: margin, y: 244, width: 507, height: 24),
size: 13.5, color: navy, weight: .bold)
let distCases: [(String, String, NSColor)] = [
("성공", "상대가 처리했고 응답도 받음", green),
("명확한 실패", "상대가 오류 응답을 보냄", red),
("연결 실패", "상대에게 요청이 도착하지 않음", orange),
("애매한 타임아웃", "처리는 됐지만 응답만 늦었을 수도 있음", purple),
]
for (i, c) in distCases.enumerated() {
let col = i % 2, row = i / 2
let x = margin + CGFloat(col) * 260
let y = CGFloat(286 + row * 86)
callout(c.0, c.1, rect: CGRect(x: x, y: y, width: 247, height: 70), color: c.2)
}
drawText("대표적인 대응 수단", CGRect(x: margin, y: 475, width: 507, height: 24),
size: 13.5, color: navy, weight: .bold)
callout("Timeout", "얼마나 기다릴지 상한을 둡니다. 짧으면 정상 요청도 실패하고, 길면 자원이 오래 묶입니다.",
rect: CGRect(x: margin, y: 515, width: 159, height: 92), color: blue)
callout("Retry", "일시 실패를 다시 시도합니다. 단, 중복 처리에 안전한 작업에서만 제한적으로 사용합니다.",
rect: CGRect(x: 218, y: 515, width: 159, height: 92), color: orange)
callout("Idempotency", "같은 요청을 여러 번 보내도 결과가 한 번 처리한 것과 같도록 만듭니다.",
rect: CGRect(x: 392, y: 515, width: 159, height: 92), color: purple)
callout("Fallback", "주 서비스가 실패하면 대체 경로·기본값·이전 데이터를 사용합니다. 대체 결과가 업무적으로 허용될 때만 가능합니다.",
rect: CGRect(x: margin, y: 630, width: 247, height: 92), color: green)
callout("Circuit breaker", "실패가 계속되는 서비스를 잠시 호출하지 않아 연쇄 장애를 막습니다. 현재 프로젝트에는 명시적 구현이 없습니다.",
rect: CGRect(x: 304, y: 630, width: 247, height: 92), color: red)
endPage(ctx)
// 4 distributed concept
beginPage(ctx, page: 4)
sectionTitle("3", "분산 시스템과 장애 대응", "한 프로그램이 아니라 여러 서비스가 네트워크로 협력하는 구조", color: blue)
callout("쉬운 비유", "한 식당 안에서 주방과 홀 직원이 말로 협업하는 것이 단일 시스템이라면, 분산 시스템은 서로 다른 건물의 팀이 전화로 협업하는 것입니다. 전화는 늦거나 끊길 수 있고, 상대가 일을 끝냈는데 답만 못 받을 수도 있습니다.",
rect: CGRect(x: margin, y: 130, width: 507, height: 105), color: blue)
drawText("프로젝트의 대표 흐름", CGRect(x: margin, y: 266, width: 507, height: 24),
size: 14, color: navy, weight: .bold)
node("Backend", "사용자 채팅 요청", rect: CGRect(x: 52, y: 315, width: 110, height: 74), color: blue)
node("HTTPX", "timeout 설정", rect: CGRect(x: 242, y: 315, width: 110, height: 74), color: cyan)
node("Agent", "협상 턴 계산", rect: CGRect(x: 432, y: 315, width: 110, height: 74), color: orange)
arrow(CGPoint(x: 162, y: 352), CGPoint(x: 242, y: 352), color: blue)
arrow(CGPoint(x: 352, y: 352), CGPoint(x: 432, y: 352), color: cyan)
drawText("성공", CGRect(x: 394, y: 414, width: 80, height: 18), size: 9, color: green, weight: .bold)
arrow(CGPoint(x: 485, y: 389), CGPoint(x: 485, y: 458), color: green)
node("응답 반영", "채팅 상태 저장", rect: CGRect(x: 430, y: 458, width: 112, height: 65), color: green)
drawText("타임아웃/실패", CGRect(x: 185, y: 414, width: 105, height: 18), size: 9, color: red, weight: .bold)
arrow(CGPoint(x: 297, y: 389), CGPoint(x: 297, y: 458), color: red)
node("안전한 실패", "ok=false 반환", rect: CGRect(x: 241, y: 458, width: 112, height: 65), color: red)
callout("중요한 함정: 타임아웃 ≠ 상대가 아무 일도 안 함", "Agent가 DB 상태를 이미 전진시킨 직후 응답만 늦었을 수 있습니다. 그래서 무조건 재시도하면 같은 턴을 두 번 처리할 위험이 있습니다. 코드가 timed_out을 따로 표시하는 이유입니다.",
rect: CGRect(x: margin, y: 566, width: 507, height: 105), color: orange)
comparison("이 장치가 없으면", "Agent가 느린 순간 Backend 요청도 끝없이 대기합니다. 무작정 재시도하면 협상 step이 두 번 전진할 수 있습니다.",
"현재 방식", "HTTP timeout을 두고 성공/일반 실패/타임아웃을 구분합니다. 호출 경계에서 예외를 응답 객체로 변환합니다.",
y: 695, color: blue)
endPage(ctx)
// 5 distributed code
beginPage(ctx, page: 5)
sectionTitle("3", "분산 시스템 — 실제 코드", "서비스 경계마다 timeout, fallback, best-effort 정책이 다릅니다.", color: blue)
codeBox("Agent 호출: 타임아웃을 별도 상태로 반환",
"backend/services/agent_client.py · lines 8191",
"""
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 1526",
"""
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 482500",
"""
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 116127",
"""
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 3569",
"""
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 1321",
"""
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 8598",
"""
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 3342",
"""
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 6984",
"""
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 3769",
"""
_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 3961",
"""
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)

View File

@ -57,8 +57,6 @@ export interface RefreshTokenResponse {
export interface Branding {
service_name?: string
logo_url?: string
primary_color?: string
email_header?: string
helpdesk?: string[] // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 비면 연락처 영역을 렌더하지 않는다
}
@ -67,7 +65,6 @@ export interface SessionBrandingResponse {
result: ApiResult
service_name: string
logo_url: string
primary_color: string
helpdesk?: string[]
}

View File

@ -43,7 +43,6 @@ export function usePreLoginBranding(): Branding | null {
const next: Branding = {
service_name: res.service_name || undefined,
logo_url: res.logo_url || undefined,
primary_color: res.primary_color || undefined,
helpdesk: res.helpdesk?.length ? res.helpdesk : undefined,
}
if (!next.service_name && !next.logo_url && !next.helpdesk) return

View File

@ -110,7 +110,8 @@ function ItemInfo() {
<div className="flex flex-1 items-start self-stretch overflow-y-auto overflow-x-hidden px-6 min-h-0">
<div className="flex flex-col items-start self-stretch flex-1 gap-2 min-w-0">
{renderRow(fieldLabel('item.code', '상품코드'), item_code)}
{renderRow(fieldLabel('item.price', '단가'), formattedPrice)}
{/* 값은 회사가 고른 협상 기준가(item_price), 호칭은 공급사 화면 고정 용어 — 회사 용어 사전을 타지 않는다. */}
{renderRow('공급가', formattedPrice)}
{renderRow(fieldLabel('item.model_name', '모델명'), item_model_name)}
{renderRow(fieldLabel('item.manufacturer', '제조사'), item_maker_name)}
{renderRow(fieldLabel('item.moq', '최소주문수량'), item_min_order_quantity)}

View File

@ -44,7 +44,7 @@ export function MainLayout({
header,
children,
}: MainLayoutProps) {
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입 — 색은 솔루션 고정
const panes = (
<>
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>

View File

@ -4,7 +4,7 @@ import { Logo } from '@/components'
import { LoginForm, usePreLoginBranding } from '@/features/auth'
export function LoginPage() {
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩을 먼저 그린다.
// 초청 링크의 session_id(없으면 직전 로그인 캐시)로 회사 브랜딩(서비스명·로고)을 먼저 그린다.
const branding = usePreLoginBranding()
// 이미 로그인된 상태면 목록으로

View File

@ -64,6 +64,22 @@ export interface RequeueRes {
requeued: boolean;
}
/** 몰별 확인 상태 — LPS common.enums.SourceState 와 1:1. 정의는 lps/docs/result-states.md. */
export type SourceState =
| "matched" // 수집·매칭 성공
| "no_match" // 수집됐으나 같은 상품이 아님
| "empty" // 검색 결과 자체가 0건
| "blocked" // 안티봇 차단 — IP 회전으로 자동 회복
| "env_blocked" // 회전 무효 — 사람이 환경/설정을 고쳐야 함
| "unavailable" // 전송 실패·가용 IP 없음 등 일시적
| "skipped"; // 그 소스를 쓰지 않음
export interface SourceInfo {
state: SourceState;
count?: number; // 수집 건수(성공 시)
error?: string; // 실패 사유 원문(운영 진단용)
}
export interface ProductItem {
product_code: string;
display_name?: string;
@ -74,6 +90,10 @@ export interface ProductItem {
final_lowest?: number;
final_source?: string;
searches: number;
/** 몰별 확인 상태. 운영 화면은 원인까지 봐야 조치를 가른다(검색어 문제 vs IP·환경 문제). */
sources?: Record<string, SourceInfo>;
/** 못 본 몰이 있어 결과가 최종이 아님 */
partial?: boolean;
}
export interface ProductListRes {
@ -105,6 +125,9 @@ export interface PricePoint {
coupang_name?: string;
coupang_url?: string;
by_mall?: MallEntry[];
/** 몰별 확인 상태. by_mall 은 가격이 있는 몰만 담으므로 '못 본 몰'은 여기에만 있다. */
sources?: Record<string, SourceInfo>;
partial?: boolean;
}
export interface PriceHistoryRes {

View File

@ -0,0 +1,62 @@
/**
* ****(). lps/docs/result-states.md.
*
* (negodata) 3 .
* **** : `blocked`( ) `env_blocked`( )
* .
*
* index.css @theme (raw hex ).
*/
import type { SourceState } from "../api/types";
type Meta = {
label: string;
color: string;
/** 한 줄 설명 — '이게 무슨 뜻이고 내가 뭘 해야 하나' */
desc: string;
/** 그 몰을 실제로 확인했는가. false 면 '없다'고 말하면 안 된다 */
confirmed: boolean;
};
export const SOURCE_STATES: Record<SourceState, Meta> = {
matched: {
label: "매칭", color: "var(--color-ok-600)", confirmed: true,
desc: "수집·매칭 성공 — 가격 확보",
},
no_match: {
label: "같은 상품 없음", color: "var(--color-neutral-400)", confirmed: true,
desc: "수집은 됐으나 같은 상품이 아님 — 검색어·규격을 의심할 것",
},
empty: {
label: "결과 0건", color: "var(--color-neutral-400)", confirmed: true,
desc: "그 몰의 검색 결과 자체가 0건 — 확인했고 정말 없다",
},
blocked: {
label: "차단", color: "var(--color-warn-600)", confirmed: false,
desc: "안티봇 차단 — IP 회전으로 자동 회복된다. 반복되면 IP 풀·예산 확인",
},
env_blocked: {
label: "환경 차단", color: "var(--color-dead-600)", confirmed: false,
desc: "회전해도 회복 불가 — 사람이 환경/게이트웨이 설정을 고쳐야 한다",
},
unavailable: {
label: "확인 못함", color: "var(--color-warn-600)", confirmed: false,
desc: "전송 실패·가용 IP 없음 등 일시적 — 잠시 후 재시도로 회복",
},
skipped: {
label: "미사용", color: "var(--color-neutral-300)", confirmed: true,
desc: "그 소스를 쓰지 않음(폴백 OFF 등)",
},
};
/** 미지의 상태도 화면을 깨뜨리지 않는다 — 값 그대로 보여주고 '모름'으로 취급한다. */
export const stateMeta = (s: string | undefined): Meta =>
SOURCE_STATES[s as SourceState] ?? {
label: s || "-", color: "var(--color-ink-400)", desc: "알 수 없는 상태", confirmed: false,
};
/** 못 본 몰 목록 — 결과가 왜 최종이 아닌지 한 줄로 설명할 때 쓴다. */
export const unconfirmedMalls = (sources?: Record<string, { state: string }>): string[] =>
Object.entries(sources ?? {})
.filter(([, v]) => !stateMeta(v?.state).confirmed)
.map(([mall]) => mall);

View File

@ -6,7 +6,8 @@ import {
Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { get, post } from "../api/client";
import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types";
import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes, SourceInfo } from "../api/types";
import { stateMeta, unconfirmedMalls } from "../lib/sourceState";
import { Button, Card, Empty, ErrorNote, Legend, Loading, PageHeader, ScrollBox, ScrollTable, SearchForm, Segmented } from "../components/ui";
import { gridProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, timeAgo, won } from "../lib/format";
@ -83,7 +84,17 @@ export default function Products() {
</div>
<div className="mt-0.5 flex justify-between text-[11px] text-ink-400">
<span>{p.product_code} · {p.searches}</span>
<span>{timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""}</span>
<span className="flex items-center gap-1">
{/* 못 본 몰이 있으면 이 값은 최종이 아니다 — 가격 옆에서 바로 보여야 오해가 없다 */}
{p.partial && (
<span className="rounded px-1 font-semibold text-warn-600"
style={{ background: "color-mix(in srgb, var(--color-warn-600) 12%, transparent)" }}
title={`확인 못한 몰: ${unconfirmedMalls(p.sources).join(", ")}`}>
</span>
)}
{timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""}
</span>
</div>
</button>
</li>
@ -215,6 +226,39 @@ const MALL_COLS = [
const srcColor = (s?: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타");
/**
* ** ** .
* '거기엔 없더라' '거기를 못 봤다' .
* 않는다: blocked( ) env_blocked( ) .
*/
function SourceStates({ sources, partial }: { sources?: Record<string, SourceInfo>; partial?: boolean }) {
const entries = Object.entries(sources ?? {});
if (entries.length === 0) return null; // 이 컬럼 추가 이전 이력 — 조용히 숨긴다
return (
<div className="mb-2 shrink-0 space-y-1 rounded border border-line-100 bg-surface-2 px-2 py-1.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{entries.map(([mall, info]) => {
const m = stateMeta(info?.state);
return (
<span key={mall} className="inline-flex items-center gap-1 text-[11px]"
title={`${m.desc}${info?.error ? `\n\n${info.error}` : ""}`}>
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: m.color }} aria-hidden />
<span className="font-semibold text-ink-500">{mall}</span>
<span style={{ color: m.color }}>{m.label}</span>
{info?.count != null && <span className="tnum text-ink-400">{info.count}</span>}
</span>
);
})}
</div>
{partial && (
<p className="text-[11px] leading-snug text-warn-600">
{unconfirmedMalls(sources).join("·")} () .
</p>
)}
</div>
);
}
function MallCompare({ point }: { point: PricePoint }) {
const [topN, setTopN] = useState<number | "all">(5);
const malls = (point.by_mall ?? [])
@ -230,6 +274,7 @@ function MallCompare({ point }: { point: PricePoint }) {
<p className="mb-1.5 shrink-0 text-[11px] text-ink-400">
<span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> ·
</p>
<SourceStates sources={point.sources} partial={point.partial} />
{shown.length === 0 ? (
<div className="grid h-full place-items-center"><Empty> </Empty></div>
) : (

View File

@ -105,6 +105,16 @@ class price_history(MAIN_BASE):
# 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...].
# 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지.
by_mall = Column(JSONB, nullable=True)
# ── 몰별 '확인했는가' ─────────────────────────────────────────────────────
# by_mall 은 **가격이 있는 몰만** 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지
# '거기를 못 봤다'인지 알 수 없었다 — 안 본 걸 없다고 말하는 셈이었다.
# {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
# state 값은 common.enums.SourceState (정의·표기 규칙은 docs/result-states.md).
sources = Column(JSONB, nullable=True)
# 결과가 완전한가. True = 못 본 몰이 있어 이 값이 최종이 아니다.
# sources 에서 유도 가능하지만 컬럼으로 둔다 — 소비자가 '어떤 상태가 확인된 것인가'라는
# 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 여기서 끝내고 사실만 넘긴다.
partial = Column(Boolean, nullable=False, server_default=text("false"))
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))

View File

@ -69,3 +69,26 @@ class JobType(Enum):
SEARCH = 1 # 최저가 검색(쿠팡=브라우저) — 무거움
OUTBOX = 2 # 외부 API 결과 전송(재시도 엔진 공유) — 가벼움
class SourceState(Enum):
"""한 상품을 **한 몰에서** 찾은 결과. 정의·표기 규칙은 docs/result-states.md 가 소스다.
가장 중요한 경계는 `확인함` ` ` 사이다:
MATCHED·NO_MATCH·EMPTY 몰을 실제로 봤다 "없다" 말해도 되는 사실
BLOCKED·ENV_BLOCKED·UNAVAILABLE 봤다 "없다" 말하면 거짓이 된다
경계를 잃으면 '차단당해 못 본 것' '그 몰엔 없음'으로 둔갑한다(실측 문제).
"""
MATCHED = 1 # 수집·매칭 성공 — 가격 확보
NO_MATCH = 2 # 수집은 됐으나 같은 상품이 없음(액세서리·다른 규격만)
EMPTY = 3 # 그 몰의 검색 결과 자체가 0건
BLOCKED = 4 # 안티봇 차단 — IP 회전으로 회복 가능(자동)
ENV_BLOCKED = 5 # 회전해도 안 되는 차단(환경·게이트웨이 설정) — 사람이 고쳐야 함
UNAVAILABLE = 6 # 전송 실패·가용 IP 없음 등 일시적 — 잠시 후 재시도로 회복
SKIPPED = 7 # 그 소스를 아예 쓰지 않음(폴백 OFF 등)
@property
def confirmed(self) -> bool:
"""그 몰을 **실제로 확인했는지**. False 면 '없다'고 단정하면 안 된다."""
return self in (SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY)

View File

@ -13,6 +13,7 @@ _FIELDS = (
"coupang_lowest", "coupang_name", "coupang_url",
"final_lowest", "final_source", "final_rating", "final_review_count",
"final_shipping_fee", "final_shipping_type", "final_shipping_label",
"partial",
)
@ -27,17 +28,20 @@ class PriceHistory:
naver_lowest, naver_name, naver_url,
coupang_lowest, coupang_name, coupang_url,
final_lowest, final_source, final_rating, final_review_count,
final_shipping_fee, final_shipping_type, final_shipping_label, by_mall)
final_shipping_fee, final_shipping_type, final_shipping_label,
by_mall, sources, partial)
VALUES
(:product_code, :job_id, :outcome, :matched_count,
:naver_lowest, :naver_name, :naver_url,
:coupang_lowest, :coupang_name, :coupang_url,
:final_lowest, :final_source, :final_rating, :final_review_count,
:final_shipping_fee, :final_shipping_type, :final_shipping_label, CAST(:by_mall AS jsonb))
:final_shipping_fee, :final_shipping_type, :final_shipping_label,
CAST(:by_mall AS jsonb), CAST(:sources AS jsonb), COALESCE(:partial, FALSE))
""")
params = {k: event.get(k) for k in _FIELDS}
by_mall = event.get("by_mall")
params["by_mall"] = json.dumps(by_mall) if by_mall is not None else None
for col in ("by_mall", "sources"): # JSONB 는 문자열로 넘겨 CAST 한다
v = event.get(col)
params[col] = json.dumps(v, ensure_ascii=False) if v is not None else None
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
try:
await s.execute(sql, params)
@ -55,7 +59,7 @@ class PriceHistory:
SELECT triggered_at, outcome, matched_count,
naver_lowest, naver_name, naver_url,
coupang_lowest, coupang_name, coupang_url,
final_lowest, final_source, by_mall
final_lowest, final_source, by_mall, sources, partial
FROM price_history
WHERE product_code = :pc
ORDER BY triggered_at DESC
@ -76,7 +80,7 @@ class PriceHistory:
sql = text(f"""
SELECT * FROM (
SELECT DISTINCT ON (product_code)
product_code, triggered_at, outcome,
product_code, triggered_at, outcome, sources, partial,
naver_lowest, coupang_lowest, final_lowest, final_source,
COALESCE(naver_name, coupang_name) AS display_name,
count(*) OVER (PARTITION BY product_code) AS searches

View File

@ -62,6 +62,8 @@
| `final_lowest` | 전체 최저가 (**그래프 Y축 핵심**) |
| `final_source` | 최종 최저가가 나온 소스(naver/coupang/gmarket/auction/st11) |
| `by_mall` | 몰별 최저가 스냅샷(JSONB, 열린 스키마) — `[{mall, source, price, shipping_fee, shipping_type, url}, …]`. G마켓·옥션·11번가 등이 늘어도 컬럼 추가 없이 담는다 |
| `sources` | **몰별 확인 상태**(JSONB) — `{"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}`. `by_mall` 은 가격이 있는 몰만 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는 이 값에만 있다. state 값은 `SourceState`([정의](result-states.md)) |
| `partial` | 결과가 **완전한가**. `true`=못 본 몰이 있어 최종이 아니다. `sources` 에서 유도 가능하지만 컬럼으로 두어, 소비자가 상태 분류 규칙을 몰라도 되게 한다 |
| `job_id` / `created_at` | 검색 잡 연결 / 생성 시각 |
> 한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 `null`(그래프 선이 빈다 — 정상).

View File

@ -157,13 +157,77 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata
- 부분 실패 시 네거티브 캐시 오염 방지
- 한 소스가 막혀도 살아있는 소스로 잡을 정상 종료
**안 된 것**
1. `_search_round``AdapterError.blocked/fatal` 을 버린다 → 몰별 상태를 못 만든다 *(가장 근본)*
2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다
3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만)
4. negodata 가 ``(없음)와 `확인 못함`(미확인)을 구분하지 못한다
### 진행 상황
**1 → 2 → (3, 4)** 순서다. 1을 안 고치면 2가 담을 내용이 없고, 2가 없으면 3·4가 읽을 게 없다.
3과 4는 같은 데이터에서 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다.
**1단계 — 몰별 상태 보존 ✅ 완료** (`feat/source-state`)
- `common/enums.py``SourceState`(7상태) 추가. `.confirmed` 로 '봤다/못 봤다' 경계를 한곳에 둔다.
- `AdapterError.state` — 어댑터가 이미 알던 구분을 실어 보낸다. `state` 를 안 주면
`blocked/fatal` 에서 유도하고, **모르면 `UNAVAILABLE`**(= 못 봤다)로 둔다.
`EMPTY` 를 기본값으로 하면 확인도 안 한 몰을 '없음'으로 단정하게 되기 때문이다.
- `browser_base` 의 raise 지점 5곳에 상태를 실었다. 갈림은 0건 종착 지점 하나다:
`blocked=False``EMPTY`(정말 없다) / `blocked=True``BLOCKED`(못 봤다).
- `_search_round` 가 문자열 대신 `{"state": ..., "count"|"error": ...}` 를 남긴다.
`EMPTY` 는 '정상 응답'으로 세므로, 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않는다.
- `_finalize_states` — 수집만 된 소스를 AI 판정 뒤 `MATCHED`/`NO_MATCH` 로 확정한다.
검증(9조합 실측): `empty``partial=False`(확정) / `blocked`·`env_blocked`·`unavailable`
`partial=True`(미확정). 테스트 12건 추가.
**2단계 — 저장할 자리 ✅ 완료** (`feat/source-state`)
- `price_history.sources`(JSONB) — 몰별 상태를 그대로 담는다. 열린 스키마라 몰이 늘거나
상태에 근거를 덧붙여도 마이그레이션이 필요 없다.
- `price_history.partial`(bool) — 결과가 완전한가. `sources` 에서 유도할 수 있지만 굳이 컬럼으로
둔다: 소비자가 '어떤 상태가 확인된 것인가'라는 **판단 규칙까지 알아야 하면 상태 정의가 두 곳으로
흩어진다**. 판단은 LPS 가 끝내고 소비자는 사실 하나만 읽는다.
- 부분 인덱스 `ix_price_history_partial` — '확인 못한 결과'만 뽑는 운영 점검용(작게 유지된다).
- 마이그레이션: `postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql` (2026-08 추가분 통합, **운영 적용 필요**)
검증(실 DB): 쿠팡 차단과 쿠팡 0건은 `by_mall` 이 둘 다 `['naver']` 로 같지만
`partial`(true/false)과 `sources.coupang.state`(blocked/empty)가 두 경우를 갈라낸다. 테스트 3건 추가.
**3단계 — lps-admin 상세 표시 ✅ 완료** (`feat/source-state`)
운영자 목적은 **진단**이라 상태를 접지 않는다 — `blocked`(자동 회복)와 `env_blocked`(사람이
고쳐야 함)를 뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치한다.
- API: `/v1/lps/products``/v1/lps/products/{code}/history` 둘 다 `sources`·`partial` 을 싣는다.
**이력은 시점마다** 실린다 — 최신 상태를 과거 시점 옆에 붙이면 오해를 부르기 때문이다.
- `lps-admin/src/lib/sourceState.ts`: 상태별 라벨·색·설명·`confirmed` 를 한곳에 둔다.
미지의 상태가 와도 화면이 깨지지 않는다(값 그대로 표시하고 '모름'으로 취급).
- 상품 목록: 못 본 몰이 있으면 `일부 확인 못함` 배지(툴팁에 어느 몰인지).
- 몰별 비교 카드 위: 몰별 상태 줄 + 수집 건수 + 실패 사유 원문(툴팁). 가격표에 없는 몰이
**왜** 없는지를 여기서 답한다.
검증: ASGI 직접 호출로 두 엔드포인트 모두 `sources`·`partial` 확인(한글 사유 포함).
`tsc` 오류 없음. 테스트 3건 추가(목록 노출 / 시점별 상태 / 옛 행 호환).
**4단계 — negodata 사용자 화면 ✅ 완료** (`feat/source-state`)
3-2절대로 **셋으로 접었다**. 사용자가 할 수 있는 건 '쓴다/다시 시도/넘어간다' 뿐이라, 원인이
달라도 다음 행동이 같으면 같은 표기다.
| 몰 칸 | 조건 |
|------|------|
| 가격 | `matched` |
| `` | `no_match` · `empty` — 확인했고 없었다 |
| **확인 못함** | `blocked` · `env_blocked` · `unavailable` — 못 봤다 |
체인 전체를 이었다: `price_history.sources/partial` → negodata 읽기 계약 → 동기화 →
`item_internet_lowest_prices` → API(`LowestPriceEntry`) → 화면.
마이그레이션: `postgres-init/alters/2026-08-07-iilp-source-state.sql` (**운영 적용 필요**)
E2E 검증(실 DB, 두 시나리오): `by_mall` 은 둘 다 `naver` 뿐인데 쿠팡 칸이
`확인 못함`(차단) / ``(0건)으로 갈린다. `tsc` 오류 없음, negodata 97 · lps 292 passed.
> 폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용.
---
**4단계 모두 완료.** 남은 개선 여지(선택):
- `no_match`(같은 상품 아님)와 `empty`(검색 0건)를 사용자 화면에서 굳이 나눌 필요는 없다고 봤다 —
나중에 "왜 없지?"라는 문의가 잦아지면 재검토.
- 잡 목록(lps-admin Jobs)에는 아직 몰별 상태를 안 붙였다. 상품 화면에서 보이므로 우선순위는 낮다.
> 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.

View File

@ -45,6 +45,10 @@ class ProductItem(BaseModel):
final_lowest: Optional[int] = None
final_source: Optional[str] = None
searches: int = Field(0, description="누적 검색(이력) 수")
# 운영 화면은 **원인까지** 봐야 한다 — '그 몰에 없었다'와 '그 몰을 못 봤다'는 조치가 다르다
# (전자는 검색어 문제, 후자는 IP·환경 문제). 사용자 화면은 이걸 접어서 보여준다.
sources: Optional[dict] = Field(None, description="몰별 확인 상태 — {몰: {state, count|error}}. state=SourceState")
partial: bool = Field(False, description="못 본 몰이 있어 결과가 최종이 아님")
class Res_ProductList(Res_WebPacketProtocol):

View File

@ -61,6 +61,9 @@ class PricePoint(BaseModel):
coupang_name: Optional[str] = None
coupang_url: Optional[str] = None
by_mall: Optional[list[dict]] = Field(None, description="몰별 최저가 스냅샷(G마켓·옥션·11번가 등 포함)")
# by_mall 은 **가격이 있는 몰만** 담는다 — 빠진 몰이 '없었다'인지 '못 봤다'인지는 아래에만 있다.
sources: Optional[dict] = Field(None, description="몰별 확인 상태 — {몰: {state, count|error}}")
partial: bool = Field(False, description="못 본 몰이 있어 이 시점 결과가 최종이 아님")
class Res_PriceHistory(Res_WebPacketProtocol):

View File

@ -87,6 +87,7 @@ class AdminService:
naver_lowest=r.get("naver_lowest"), coupang_lowest=r.get("coupang_lowest"),
final_lowest=r.get("final_lowest"), final_source=r.get("final_source"),
searches=int(r.get("searches") or 0),
sources=r.get("sources"), partial=bool(r.get("partial")),
) for r in await self.history.list_products(q, limit)]
return res

View File

@ -95,6 +95,7 @@ class LpsService:
naver_name=r["naver_name"], naver_url=r["naver_url"],
coupang_name=r["coupang_name"], coupang_url=r["coupang_url"],
by_mall=r.get("by_mall"),
sources=r.get("sources"), partial=bool(r.get("partial")),
)
for r in rows
]

View File

@ -20,6 +20,7 @@ from abc import abstractmethod
from patchright.async_api import async_playwright
from common.logger import LOG
from common.enums import SourceState
from config.server_configs import decodo_config, worker_config
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
from services.search.rate_limiter import RateLimiter
@ -253,7 +254,7 @@ class BrowserSearchAdapter(SearchAdapter):
self._note_result(False)
raise AdapterError(
f"{self.source} 가용 프록시 IP 없음(전부 임대/휴식/쿨다운) — 잠시 후 재시도",
source=self.source)
source=self.source, state=SourceState.UNAVAILABLE)
kwargs["proxy"] = self._proxy.playwright_proxy()
await self._begin_ip_session(self._proxy.current_port)
else:
@ -409,7 +410,9 @@ class BrowserSearchAdapter(SearchAdapter):
self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}", kind="proxy_error")
continue
self._note_result(False)
raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex
# 페이지를 못 받았다 = 그 몰을 **못 봤다**. '없음'과 섞이면 안 된다.
raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source,
state=SourceState.UNAVAILABLE) from ex
# 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백
self.last_bytes = self._net_bytes if self._cdp is not None else len(html.encode("utf-8"))
@ -436,7 +439,8 @@ class BrowserSearchAdapter(SearchAdapter):
f"[{self.source}] 구조적 차단 '{marker}' — IP 회전으로 회복 불가. "
f"게이트웨이/국가 설정을 확인하세요([DecodoConfig].kr_host 등)")
raise AdapterError(f"{self.source} 구조적 차단 ({marker}) — 설정 확인 필요",
source=self.source, blocked=True, fatal=True)
source=self.source, blocked=True, fatal=True,
state=SourceState.ENV_BLOCKED)
# 확신도에 따라 대응을 가른다.
# 알려진 마커 사이트가 대놓고 막았다 → 태울 근거가 있다
# short_html 폴백 '0건인데 페이지가 짧다'는 정황일 뿐이다. 진짜 '검색결과
@ -459,7 +463,8 @@ class BrowserSearchAdapter(SearchAdapter):
raise AdapterError(
f"{self.source} 환경 차단 (IP {len(self._fresh_ip_blocks)}개가 첫 요청부터 차단, "
f"최근 마커={marker}) — IP 회전으로 회복 불가",
source=self.source, blocked=True, fatal=True)
source=self.source, blocked=True, fatal=True,
state=SourceState.ENV_BLOCKED)
if known and self.uses_proxy:
self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)
@ -471,7 +476,13 @@ class BrowserSearchAdapter(SearchAdapter):
if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기
self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block")
self._note_result(False)
raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", source=self.source, blocked=blocked)
# 여기가 '없음'과 '못 봄'이 갈리는 유일한 지점이다.
# blocked=False → 페이지는 정상인데 상품이 0건 = 그 몰에 **정말 없다**(EMPTY)
# blocked=True → 차단 페이지를 받은 것 = **못 봤다**(BLOCKED)
# 이 구분을 여기서 안 실어 보내면 위쪽에서는 영영 알 수 없다.
raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})",
source=self.source, blocked=blocked,
state=SourceState.BLOCKED if blocked else SourceState.EMPTY)
async def _report_detection(self, query: str, marker: str, html_len: int):
# 경과는 **IP 세션** 기준 — 'ip_req#N 을 몇 초 만에 쐈나'가 차단 진단의 축이다.

View File

@ -12,6 +12,8 @@ from typing import Optional
from pydantic import BaseModel, Field
from common.enums import SourceState
class NormalizedProduct(BaseModel):
"""소스 무관 정규화 상품 스키마. 어댑터의 유일한 출력 계약."""
@ -55,13 +57,25 @@ class AdapterError(Exception):
fatal=True **재시도해도 절대 되는 차단**이다 IP 바꿔도 같은 결과가 나오는
구조적 원인(: 네이버 msearch 해외 IP 접근 = 게이트웨이 설정이 틀림).
호출부는 회전·재시도를 멈추고 설정을 고쳐야 한다.
`state` **'그 몰을 봤는가'** 담는다. blocked/fatal '어떻게 대응할까'(회전·재시도)
위한 값이고, state '사용자에게 뭐라고 말할까' 위한 값이라 쓰임이 다르다.
특히 blocked=False 하나에 가지가 섞여 있어 state 없이는 갈라낼 없다:
결과 0(EMPTY) 몰을 봤고 정말 없었다 "없음"이라 말해도 된다
전송 실패(UNAVAILABLE) 몰을 봤다 "없음"이라 말하면 거짓
"""
def __init__(self, message: str, *, source: str, blocked: bool = False, fatal: bool = False):
def __init__(self, message: str, *, source: str, blocked: bool = False, fatal: bool = False,
state: SourceState | None = None):
super().__init__(message)
self.source = source
self.blocked = blocked
self.fatal = fatal
# state 를 안 준 옛 호출부도 맞게 동작하도록 blocked/fatal 에서 유도한다.
# (모르면 UNAVAILABLE — '못 봤다' 쪽이 안전한 기본값이다. EMPTY 로 잘못 넘기면
# 확인도 안 한 몰을 '없음'으로 단정하게 된다)
self.state = state or (SourceState.ENV_BLOCKED if fatal else
SourceState.BLOCKED if blocked else SourceState.UNAVAILABLE)
class SearchAdapter(ABC):

View File

@ -95,6 +95,52 @@ async def test_products_list_latest_snapshot(client, clean_all):
assert len(r.json()["items"]) == 1
# ---- 몰별 확인 상태 (2026-08-07, 3단계) -------------------------------------
# 운영 화면은 '왜 그 몰 값이 없나'에 답할 수 있어야 한다 — by_mall 에 없는 몰이
# '거기엔 없더라'인지 '거기를 못 봤다'인지는 sources 에만 있다.
async def test_products_list_exposes_source_states(client, clean_all):
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO price_history (product_code, outcome, final_lowest, partial, sources, triggered_at)
VALUES ('S1', 'found', 9000, true,
'{"naver": {"state": "matched", "count": 40},
"coupang": {"state": "env_blocked", "error": "사용권한이 제한된"}}'::jsonb,
now())
"""))
item = (await client.get("/v1/lps/products")).json()["items"][0]
assert item["partial"] is True
assert item["sources"]["coupang"]["state"] == "env_blocked"
assert "사용권한이 제한된" in item["sources"]["coupang"]["error"] # 원인 원문이 운영자에게 간다
async def test_history_points_carry_state_per_point(client, clean_all):
"""상태는 시점마다 다르다 — 최신 상태를 과거 시점 옆에 붙이면 오해를 부른다."""
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO price_history (product_code, outcome, final_lowest, partial, sources, triggered_at)
VALUES ('S2', 'found', 9000, true,
'{"coupang": {"state": "blocked"}}'::jsonb, now() - interval '1 hour'),
('S2', 'found', 8500, false,
'{"coupang": {"state": "matched", "count": 60}}'::jsonb, now())
"""))
pts = (await client.get("/v1/lps/products/S2/history")).json()["points"]
assert [p["partial"] for p in pts] == [True, False]
assert pts[0]["sources"]["coupang"]["state"] == "blocked"
assert pts[1]["sources"]["coupang"]["state"] == "matched"
async def test_old_rows_without_state_still_work(client, clean_all):
"""이 컬럼 추가 이전 이력도 그대로 읽혀야 한다(마이그레이션 전 데이터)."""
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO price_history (product_code, outcome, final_lowest, triggered_at)
VALUES ('S3', 'found', 7000, now())
"""))
item = (await client.get("/v1/lps/products")).json()["items"][0]
assert item["partial"] is False and item.get("sources") is None
# ---- 통계 3종 ---------------------------------------------------------------
async def test_ip_session_stats(client, clean_all):

View File

@ -113,3 +113,42 @@ async def test_snapshot_carries_trust_of_the_lowest_offer():
e = rec.events[0]
assert e["final_lowest"] == 900 and e["final_source"] == "naver"
assert e["final_rating"] is None and e["final_review_count"] is None # 미검증 오퍼임이 드러난다
# ── 몰별 확인 상태 (2026-08-07, 2단계) ──────────────────────────────────
# by_mall 은 '가격이 있는 몰'만 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지
# '거기를 못 봤다'인지 구분되지 않았다 — sources/partial 이 그 자리를 메운다.
async def test_records_source_states_and_partial(ph, db_engine):
await ph.record({
"product_code": "SRC1", "outcome": "found", "final_lowest": 9000, "partial": True,
"sources": {"naver": {"state": "matched", "count": 40},
"coupang": {"state": "blocked", "error": "AdapterError: 차단"}},
})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT partial, sources FROM price_history WHERE product_code='SRC1'"))).first()
assert row.partial is True
assert row.sources["coupang"]["state"] == "blocked"
assert row.sources["naver"]["count"] == 40
async def test_partial_defaults_to_false_when_absent(ph, db_engine):
"""옛 호출부(값을 안 주는 경로)도 깨지지 않아야 한다 — 기본은 '완전한 결과'."""
await ph.record({"product_code": "SRC2", "outcome": "not_found"})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT partial, sources FROM price_history WHERE product_code='SRC2'"))).first()
assert row.partial is False and row.sources is None
async def test_source_state_survives_korean_text(ph, db_engine):
"""error 메시지에 한글이 섞여도 JSONB 가 깨지지 않아야 한다(ensure_ascii=False)."""
await ph.record({
"product_code": "SRC3", "outcome": "found", "partial": True,
"sources": {"coupang": {"state": "env_blocked", "error": "사용권한이 제한된 페이지"}},
})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT sources FROM price_history WHERE product_code='SRC3'"))).first()
assert "사용권한이 제한된" in row.sources["coupang"]["error"]

View File

@ -0,0 +1,107 @@
"""몰(소스)별 상태 테스트 — '그 몰에 없었다''그 몰을 못 봤다'를 가르는 계약.
정의는 docs/result-states.md 소스다. 여기서 지키는 것은 하나다:
**확인한 (matched/no_match/empty) (blocked/env_blocked/unavailable) 섞이지 않는다.**
섞이면 차단당해 몰이 화면에서 '그 몰엔 없음'으로 둔갑한다(실제로 그랬다).
"""
import pytest
from common.enums import SourceState
from services.search.contract import AdapterError, NormalizedProduct, SearchAdapter
from worker.handlers import _finalize_states, build_search_handler
# ── 상태 자체의 계약 ────────────────────────────────────────────────────
def test_confirmed_separates_seen_from_unseen():
"""이 경계가 무너지면 나머지 로직이 전부 틀어진다."""
assert [s for s in SourceState if s.confirmed] == [
SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY]
for s in (SourceState.BLOCKED, SourceState.ENV_BLOCKED, SourceState.UNAVAILABLE, SourceState.SKIPPED):
assert not s.confirmed, s
# ── AdapterError 가 상태를 싣는가 ───────────────────────────────────────
@pytest.mark.parametrize("kw,expected", [
(dict(), SourceState.UNAVAILABLE), # 아무것도 모르면 '못 봤다'가 안전
(dict(blocked=True), SourceState.BLOCKED),
(dict(blocked=True, fatal=True), SourceState.ENV_BLOCKED),
(dict(state=SourceState.EMPTY), SourceState.EMPTY),
])
def test_error_state_defaults(kw, expected):
"""state 를 안 준 옛 호출부도 맞게 동작해야 한다 — 특히 기본값이 EMPTY 면 안 된다
(확인도 몰을 '없음'으로 단정하게 된다)."""
assert AdapterError("x", source="s", **kw).state is expected
# ── 수집 뒤 매칭 결과로 확정 ────────────────────────────────────────────
def test_collected_but_unmatched_becomes_no_match():
per_source = {"naver": {"state": "matched"}, "coupang": {"state": "matched"}}
matched = [NormalizedProduct(source="naver", name="x", price=1)]
_finalize_states(per_source, matched)
assert per_source["naver"]["state"] == "matched"
assert per_source["coupang"]["state"] == "no_match" # 가져왔지만 같은 상품이 아니었다
def test_finalize_never_overwrites_a_failure_state():
"""실패 상태는 이미 확정이다 — 매칭 결과로 덮으면 '못 봤다'가 사라진다."""
per_source = {"coupang": {"state": "blocked"}, "naver": {"state": "empty"}}
_finalize_states(per_source, [])
assert per_source["coupang"]["state"] == "blocked"
assert per_source["naver"]["state"] == "empty"
# ── 핸들러 통합: 상태가 결과까지 실려 나가는가 ──────────────────────────
class _A(SearchAdapter):
def __init__(self, source, mode): self.source, self.mode = source, mode
async def search(self, q, limit=40):
if self.mode == "hit":
return [NormalizedProduct(source=self.source, name="생수 2L", price=9000)]
if self.mode == "empty":
raise AdapterError("결과 없음", source=self.source, state=SourceState.EMPTY)
if self.mode == "blocked":
raise AdapterError("차단", source=self.source, blocked=True, state=SourceState.BLOCKED)
raise RuntimeError("우리 코드 버그")
def _job():
return {"job_type": 1, "job_id": "J", "attempts": 1, "max_attempts": 3,
"payload": {"product_code": "P", "product_name": "생수"}}
async def _run(modes):
return await build_search_handler({s: _A(s, m) for s, m in modes.items()})(_job())
async def test_empty_is_a_confirmed_answer_not_a_gap():
"""봤는데 없었으면 결과는 **확정**이다 — partial 로 흐리면 안 된다."""
r = await _run({"naver": "hit", "coupang": "empty"})
assert r["sources"]["coupang"]["state"] == "empty"
assert r["partial"] is False and r["sources_failed"] == []
async def test_blocked_marks_the_result_incomplete():
"""못 본 몰이 있으면 그 결과는 최종이 아니다."""
r = await _run({"naver": "hit", "coupang": "blocked"})
assert r["sources"]["coupang"]["state"] == "blocked"
assert r["partial"] is True and r["sources_failed"] == ["coupang"]
async def test_unknown_exception_counts_as_unseen():
"""우리 코드 버그로 못 본 것도 '없음'이라 말하면 안 된다."""
r = await _run({"naver": "hit", "coupang": "boom"})
assert r["sources"]["coupang"]["state"] == "unavailable"
assert r["partial"] is True
async def test_all_empty_is_a_definitive_not_found():
"""전부 확인했고 없었다 → 확정적 not_found(네거티브 캐시에 넣어도 되는 상태)."""
r = await _run({"naver": "empty", "coupang": "empty"})
assert r["outcome"] == "not_found" and r["partial"] is False
async def test_all_blocked_is_not_an_answer():
"""전부 못 봤으면 '없음'이 아니라 '모름'이다 — 재시도해야 한다."""
with pytest.raises(RuntimeError):
await _run({"naver": "blocked", "coupang": "blocked"})

View File

@ -16,7 +16,7 @@
import asyncio
import time
from common.enums import JobType
from common.enums import JobType, SourceState
from common.logger import LOG
from services.metrics import SearchMetrics
from services.search.contract import SearchAdapter, NormalizedProduct
@ -40,6 +40,20 @@ def _reap_abandoned(task: asyncio.Task):
LOG.d(f"[fallback] 데드라인 초과 태스크 종료(무시): {type(ex).__name__}")
def _finalize_states(per_source: dict, matched: list[NormalizedProduct]) -> dict:
"""수집 성공 소스의 상태를 **매칭 결과로 확정**한다(제자리 수정 후 반환).
수집 단계에선 '가져왔다'까지만 있다. '같은 상품이었나' AI 판정을 거쳐야 알므로
여기서 MATCHED / NO_MATCH 가른다. 실패 상태(BLOCKED·EMPTY ) 이미 확정이라 건드리지 않는다.
"""
hit = {p.source for p in matched}
collected = SourceState.MATCHED.name.lower()
for src, info in per_source.items():
if info.get("state") == collected and src not in hit:
info["state"] = SourceState.NO_MATCH.name.lower()
return per_source
def _price_snapshot(matched: list[NormalizedProduct]) -> dict:
"""매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용)."""
def lowest(src):
@ -87,10 +101,20 @@ def build_search_handler(
use = list(sources) if sources else list(adapters.keys())
fallbacks = fallback_adapters or {}
async def _record_history(product_code: str, job_id, outcome: str, matched: list):
async def _record_history(product_code: str, job_id, outcome: str, matched: list,
sources: dict | None = None):
"""price_history 1행 기록.
sources 함께 남기는 중요하다 by_mall **가격이 있는 몰만** 담으므로, 어떤 몰이
빠졌을 '거기엔 없더라'인지 '거기를 못 봤다'인지 없이는 없다.
partial 여기서 판단해 사실로 넘긴다(소비자가 상태 분류 규칙을 필요 없게).
"""
if history is None:
return
event = {"product_code": product_code, "job_id": job_id, "outcome": outcome, **_price_snapshot(matched)}
confirmed = {s.name.lower() for s in SourceState if s.confirmed}
partial = any((i or {}).get("state") not in confirmed for i in (sources or {}).values())
event = {"product_code": product_code, "job_id": job_id, "outcome": outcome,
"sources": sources or None, "partial": partial, **_price_snapshot(matched)}
try:
await history.record(event)
except Exception as ex:
@ -119,11 +143,17 @@ def build_search_handler(
products, per_source, ok_sources = [], {}, []
for src, res in zip(use, results):
if isinstance(res, Exception):
per_source[src] = {"error": f"{type(res).__name__}: {res}"}
LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}")
# ⚠️ 예외를 문자열로만 남기면 '그 몰에 없었다'와 '그 몰을 못 봤다'가 같아진다.
# AdapterError 는 이미 state 로 그걸 알고 있으므로 **구조화해서 보존**한다.
# (AdapterError 가 아닌 예외 = 우리 코드 버그 → 못 본 것으로 본다)
state = getattr(res, "state", None) or SourceState.UNAVAILABLE
per_source[src] = {"state": state.name.lower(), "error": f"{type(res).__name__}: {res}"}
LOG.w(f"[{src}] 검색 실패({state.name}): {type(res).__name__}: {res}")
if state.confirmed:
ok_sources.append(src) # EMPTY = 봤는데 없던 것 → '정상 응답'으로 센다
else:
products.extend(res)
per_source[src] = {"count": len(res)}
per_source[src] = {"state": SourceState.MATCHED.name.lower(), "count": len(res)}
ok_sources.append(src)
return products, per_source, ok_sources
@ -233,6 +263,7 @@ def build_search_handler(
matched = [c for c, v in zip(candidates, verdicts) if v.is_match]
stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)})
candidates = matched
_finalize_states(per_source, candidates) # 수집만 된 소스를 matched/no_match 로 확정
last_stages, last_sources = stages, per_source
last_ok, last_failed = ok_sources, failed_sources
@ -247,7 +278,7 @@ def build_search_handler(
partial=bool(failed_sources), metrics=metrics.snapshot())
if failed_sources:
LOG.w(f"[partial] {failed_sources} 없이 결과를 냈습니다 — 그 몰의 더 싼 값은 못 봤을 수 있습니다")
await _record_history(cache_key, job.get("job_id"), "found", candidates)
await _record_history(cache_key, job.get("job_id"), "found", candidates, per_source)
return result
# 0매칭 + 일부 소스 실패. **살아있는 소스가 하나라도 있으면 그 결과로 진행한다** —
@ -266,7 +297,7 @@ def build_search_handler(
result.update(outcome="error", query=query, rounds_tried=rounds_done,
sources=per_source, sources_ok=[], sources_failed=failed_sources,
partial=True, metrics=metrics.snapshot())
await _record_history(cache_key, job.get("job_id"), "error", [])
await _record_history(cache_key, job.get("job_id"), "error", [], per_source)
return result
raise RuntimeError(f"모든 소스 실패로 0매칭(round={label}) — 잡 재시도: {per_source}")
@ -282,7 +313,7 @@ def build_search_handler(
partial=partial, metrics=metrics.snapshot())
if partial:
LOG.w(f"[partial] {last_failed} 없이 not_found — 네거티브 캐시는 건너뜁니다(그 몰엔 있었을 수 있음)")
await _record_history(cache_key, job.get("job_id"), "not_found", [])
await _record_history(cache_key, job.get("job_id"), "not_found", [], last_sources)
return result
return handler

View File

@ -136,8 +136,16 @@ class item_internet_lowest_prices(MainTableMixin, MAIN_BASE):
lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용
# 몰별 최저가 스냅샷 — lps_db.price_history.by_mall 을 그대로 미러링한다(열린 스키마).
# [{source, mall_name, price, shipping_fee, shipping_type, name, detail_url}, ...] 가격 오름차순.
# 매칭된 몰만 들어오므로, 특정 몰이 없으면 그 몰은 빈손이었다는 뜻이다(네이버 실패/쿠팡만 성공 구분).
# 매칭된 몰만 들어온다. 그래서 **특정 몰이 없는 이유**는 여기서 알 수 없다 —
# '그 몰엔 없었다'인지 '그 몰을 못 봤다(차단)'인지는 아래 sources 가 답한다.
by_mall = Column(JSONB, nullable=True)
# 몰별 확인 상태 — lps_db.price_history.sources 미러링(열린 스키마).
# {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
# state 값 정의는 lps/docs/result-states.md (LPS common.enums.SourceState).
sources = Column(JSONB, nullable=True)
# 못 본 몰이 있어 결과가 최종이 아님. LPS 가 판단해 내려준 사실을 그대로 싣는다 —
# 화면이 '어떤 상태가 확인된 것인가'를 다시 판정하면 상태 정의가 두 곳으로 흩어진다.
partial = Column(Boolean, nullable=False, server_default=text("false"))
crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준)

View File

@ -29,7 +29,9 @@ _price_history = table(
column("naver_url"),
column("coupang_name"), # 쿠팡 최저가 상품명/링크
column("coupang_url"),
column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 어느 몰이 건졌고 어느 쪽이 빈손인지
column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 가격이 **있는** 몰만
column("sources"), # 몰별 확인 상태 — 빠진 몰이 '없었다'인지 '못 봤다'인지는 여기에만 있다
column("partial"), # 못 본 몰이 있어 결과가 최종이 아님(LPS 판단 결과)
column("created_at"),
)
@ -118,6 +120,8 @@ class LpsSyncCRUD(ILpsSyncCRUD):
_price_history.c.coupang_name,
_price_history.c.coupang_url,
_price_history.c.by_mall,
_price_history.c.sources,
_price_history.c.partial,
_price_history.c.created_at,
).order_by(_price_history.c.created_at.asc())
if since is not None:

View File

@ -55,6 +55,10 @@ class IQuotationCRUD(ABC):
async def get_company_settings(self, cdb: AsyncSession, user_id) -> dict:
pass
@abstractmethod
async def get_company_brand(self, cdb: AsyncSession, user_id) -> Tuple[str, dict]:
pass
@abstractmethod
async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType:
pass
@ -408,6 +412,24 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return {}
async def get_company_brand(self, cdb: AsyncSession, user_id):
# 이 유저가 속한 회사의 (이름, settings). 초청 메일 헤더 기본값이 회사명이라 이름까지 같이 읽는다.
try:
query = (
select(companies.name, companies.settings)
.select_from(users)
.join(companies, companies.company_id == users.company_id)
.where(users.user_id == user_id, companies.deleted == False) # noqa: E712
.limit(1)
)
err, rows = await DB_SESSION_MNG.execute(cdb, query)
if err != ErrorType.SUCCESS or not rows:
return "", {}
return rows[0][0] or "", rows[0][1] or {}
except Exception as ex:
LOG.e_no_callstack(ex)
return "", {}
async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
"""item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력."""
try:

View File

@ -12,4 +12,5 @@ openpyxl
httpx
apscheduler>=3.10
azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email)
aiohttp>=3.9 # azure aio 클라이언트(EmailClient.aio)의 HTTP 전송 계층
aiosmtplib>=3.0 # 초청 메일 폴백(SMTP)

View File

@ -70,3 +70,13 @@ class Req_UpdateCompanySettings(CompanySettingsProtocol):
class Res_CompanySettings(Res_WebPacketProtocol):
settings: Optional[dict] = None
class Req_PreviewInviteEmail(CompanySettingsProtocol):
# 저장 전 편집값으로 초청 메일을 렌더한다. settings.branding 서브키 중 email_* 만 쓴다.
branding: dict = {}
class Res_PreviewInviteEmail(Res_WebPacketProtocol):
subject: str = ""
html: str = ""

View File

@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner
from services.company_settings_service import CompanySettingsService
from .protocol import Req_UpdateCompanySettings, Res_CompanySettings
from .protocol import Req_PreviewInviteEmail, Req_UpdateCompanySettings, Res_CompanySettings, Res_PreviewInviteEmail
# 회사별 커스터마이징 설정. 조회=로그인 유저 전원(앱 부팅 시 브랜딩/라벨 로드), 수정=최고관리자(OWNER) 전용.
router = APIRouter(prefix="/v1/company/settings", tags=["CompanySettings"], responses={404: {"description": "Not found"}})
@ -19,3 +19,11 @@ async def update_settings(
req: Req_UpdateCompanySettings, service: CompanySettingsService = Depends(), owner: UserInfo = Depends(RequireOwner)
):
return RemoveNoneResponse(await service.update_settings(owner.company_id, req))
# 저장 전 편집값을 그대로 렌더한다(쓰기 없음) — 로그인만 요구. 회사명은 토큰의 회사로 채운다.
@router.post(path="/email-preview", response_model=Res_PreviewInviteEmail, summary="협상 초청 메일 미리보기")
async def preview_invite_email(
req: Req_PreviewInviteEmail, service: CompanySettingsService = Depends(), user: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.preview_invite_email(user.company_id, req))

View File

@ -145,9 +145,16 @@ class LowestPriceEntry(WebPacketProtocol):
fail_reason: Optional[str] = None
lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용
lp_url: Optional[str] = None # 찾은 판매 페이지 링크
# 몰별 최저가 스냅샷(가격 오름차순). 매칭된 몰만 들어오므로, 없는 몰은 그 회차에 빈손이었다는 뜻.
# 몰별 최저가 스냅샷(가격 오름차순). **가격이 있는 몰만** 들어온다 —
# 그래서 없는 몰이 '거기엔 없었다'인지 '거기를 못 봤다'인지는 아래 sources 가 답한다.
# [{source: 'naver'|'coupang'|…, mall_name, price, shipping_fee, shipping_type, name, detail_url}]
by_mall: Optional[list[dict]] = None
# 몰별 확인 상태 — {"naver": {"state": "matched"}, "coupang": {"state": "blocked"}}.
# 화면은 이걸 셋으로 접어 쓴다: 가격 / ''(없음) / '확인 못함'(미확인).
# state 정의는 lps/docs/result-states.md.
sources: Optional[dict] = None
# 못 본 몰이 있어 결과가 최종이 아님. LPS 가 판단한 사실이라 화면은 그대로 쓰면 된다.
partial: bool = False
crawl_end_time: Optional[datetime] = None

View File

@ -3,6 +3,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query
from common.enums import UserRole
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.quotation import QuotationService
@ -42,7 +43,9 @@ async def list_quotations(
mine: bool = Query(False, description="내 견적만 보기(작성자=로그인 유저)"),
pg: PageParams = Depends(),
):
owner = user_info.user_id if mine else None
# 일반(USER)은 mine 파라미터와 무관하게 본인 견적만 — 타 담당자 견적 조회는 OWNER 이상만.
force_mine = (user_info.role or 0) < UserRole.OWNER.value
owner = user_info.user_id if (mine or force_mine) else None
return RemoveNoneResponse(await service.list_quotations(user_info.company_id, owner, search, status, type, start_from, start_to, pg))
@ -84,12 +87,12 @@ async def regenerate_quotation(
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
@router.get(path="/{qt_id}/status", response_model=Res_QuotationStatus, summary="견적 상태 조회")
async def get_quotation_status(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_status(str(qt_id), user_info.company_id))
return RemoveNoneResponse(await service.get_status(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
@router.get(path="/{qt_id}/sessions", response_model=Res_QuotationSessions, summary="참여현황")
async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_sessions(str(qt_id), user_info.company_id))
return RemoveNoneResponse(await service.list_sessions(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송")
@ -99,12 +102,12 @@ async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), u
@router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세")
async def get_session_chat(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_chats(str(session_id), user_info.company_id))
return RemoveNoneResponse(await service.list_chats(str(session_id), user_info.company_id, user_info.user_id, user_info.role))
@router.get(path="/session/{session_id}/target-breakdown", response_model=Res_TargetBreakdown, summary="세션 목표가 산정내역")
async def get_target_breakdown(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_target_breakdown(str(session_id), user_info.company_id))
return RemoveNoneResponse(await service.get_target_breakdown(str(session_id), user_info.company_id, user_info.user_id, user_info.role))
@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송")
@ -114,12 +117,12 @@ async def notify_session(session_id: UUID, service: QuotationService = Depends()
@router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과")
async def get_quotation_result(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_result(str(qt_id), user_info.company_id))
return RemoveNoneResponse(await service.get_result(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
@router.get(path="/{qt_id}/cards", response_model=Res_QuotationCards, summary="견적 사용 카드")
async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_cards(str(qt_id), user_info.company_id))
return RemoveNoneResponse(await service.list_cards(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))
@router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제")
@ -130,4 +133,4 @@ async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), u
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----
@router.get(path="/{qt_id}", response_model=Res_Quotation, summary="견적 조회")
async def get_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_quotation(str(qt_id), user_info.company_id))
return RemoveNoneResponse(await service.get_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role))

View File

@ -1,12 +1,21 @@
import uuid
from datetime import timedelta
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import companies
from common.enums import DBWRType, ErrorType
from common.utils.gtime import GTime
from config.server_configs import web_server_config
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.company.protocol import Req_UpdateCompanySettings, Res_CompanySettings
from router.v1.company.protocol import (
Req_PreviewInviteEmail,
Req_UpdateCompanySettings,
Res_CompanySettings,
Res_PreviewInviteEmail,
)
from services.email import build_invite_email
class CompanySettingsService:
@ -43,3 +52,25 @@ class CompanySettingsService:
return res
res.settings = req.settings
return res
async def preview_invite_email(self, company_id: str, req: Req_PreviewInviteEmail) -> Res_PreviewInviteEmail:
"""저장 전 branding 편집값을 샘플 견적 데이터로 렌더한다(발송 없음).
실제 발송(services/quotation/invites.py) 같은 build_invite_email 타므로 미리보기=실물.
회사명은 헤더 기본값이라 실발송과 동일하게 채워 보여준다."""
res = Res_PreviewInviteEmail()
_, company = await DB_SESSION_MNG.execute_lambda(
companies.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_company(s, uuid.UUID(company_id)),
)
base = (web_server_config.nego_chat_url or "").rstrip("/")
res.subject, res.html, _ = build_invite_email(
supplier_name="샘플 협력사",
quotation_name="복사용지 A4 80g 외 2건",
qt_number="EST-2026-0801",
end_time=GTime.UTC() + timedelta(days=3),
chat_url=f"{base}/chat?session_id=00000000-0000-0000-0000-000000000000",
company_name=(company.name if company else "") or "",
branding=req.branding,
)
return res

View File

@ -9,6 +9,7 @@ HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환).
"""
from __future__ import annotations
import re
from datetime import datetime
from email.message import EmailMessage
from html import escape
@ -21,6 +22,12 @@ from config.server_configs import mail_config
_KST = ZoneInfo("Asia/Seoul")
# 회사 CI(settings.branding) 미설정 시 기본값 — 설정 화면 placeholder 와 같은 값이어야 한다.
# 색 기본값은 공급사 협상 포털(frontend --brand-600)과 동일 — 메일에서 포털로 이어지는 화면이 한 브랜드로 보이게.
_DEFAULT_EMAIL_COLOR = "#3182f6"
_DEFAULT_EMAIL_HEADER = "NEGODATA"
_DEFAULT_EMAIL_GREETING = "아래 견적 건의 협상에 참여해 주세요."
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
_TEMPLATE_DIR = Path(__file__).parent / "email_templates"
_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8"))
@ -96,17 +103,27 @@ def build_invite_email(
qt_number: str,
end_time: datetime | None,
chat_url: str,
email_header: str | None = None,
company_name: str = "",
branding: dict | None = None,
) -> tuple[str, str, str]:
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지).
공급사는 링크로 협상 화면에 진입해 입찰한다.
branding = companies.settings.branding 헤더는 회사 CI(logo_url·service_name)
그대로 쓴다(메일 전용 이중 설정 금지). 메일 전용 키는 인사 문구(email_greeting) 하나뿐.
헤더는 로고 있으면 로고, 없으면 service_name 회사명(company_name) NEGODATA 텍스트
공급사에겐 솔루션명보다 발주사가 보여야 한다. 색은 회사별 커스텀 없이 솔루션 기본색 고정.
레이아웃은 고정이고 값만 갈아끼우므로, 어떤 값을 넣어도 메일이 깨지지 않는다.
"""
b = branding or {}
sp = supplier_name or "협력사"
deadline = _fmt_deadline(end_time) or "미정"
subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청"
header_name = (b.get("service_name") or "").strip() or (company_name or "").strip() or _DEFAULT_EMAIL_HEADER
greeting = (b.get("email_greeting") or "").strip() or _DEFAULT_EMAIL_GREETING
# HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력).
html = _INVITE_HTML.substitute(
supplier_name=escape(sp),
@ -114,14 +131,27 @@ def build_invite_email(
qt_number=escape(qt_number),
deadline=escape(deadline),
chat_url=escape(chat_url),
email_header=escape(email_header or "NEGODATA"),
brand_color=_DEFAULT_EMAIL_COLOR,
header_content=_header_content(header_name, (b.get("logo_url") or "").strip()),
email_greeting=escape(greeting),
)
text = (
f"{sp} 담당자님, 아래 견적 건의 협상에 참여해 주세요.\n\n"
f"{sp} 담당자님, {greeting}\n\n"
f" - 견적명: {quotation_name}\n"
f" - 견적번호: {qt_number}\n"
f" - 협상 마감: {deadline}\n\n"
f"협상 참여 링크: {chat_url}\n"
f"협상 참여 링크: {chat_url}\n\n"
f"— negotium B2B 구매협상 솔루션\n"
)
return subject, html, text
def _header_content(name: str, logo_url: str) -> str:
"""헤더(흰 배경) 내용물 — 회사 CI 로고가 있으면 이미지(회사명은 alt 로), 없으면 회사명 텍스트. escape 는 여기서 끝낸다."""
if logo_url:
return (
f'<img src="{escape(logo_url)}" alt="{escape(name)}" height="32" '
f'style="display:block;height:32px;max-width:220px;border:0;margin:0 auto;" />'
)
return f'<span style="color:#191f28;font-size:18px;font-weight:800;letter-spacing:-0.4px;">{escape(name)}</span>'

View File

@ -1,37 +1,43 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;margin:0;padding:24px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f2f4f6;margin:0;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="width:480px;max-width:480px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;font-family:'Apple SD Gothic Neo',-apple-system,'Segoe UI',Roboto,'Malgun Gothic',sans-serif;">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="width:480px;max-width:480px;background-color:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e5e8eb;font-family:Pretendard,'Apple SD Gothic Neo',-apple-system,'Segoe UI',Roboto,'Malgun Gothic',sans-serif;">
<!-- 헤더 바 -->
<!-- 브랜드 액센트 — 회사 CI 색(primary_color) -->
<tr>
<td style="background-color:#2563eb;padding:18px 28px;">
<span style="color:#ffffff;font-size:16px;font-weight:700;letter-spacing:1px;">$email_header</span>
<td height="4" bgcolor="$brand_color" style="height:4px;font-size:0;line-height:0;">&nbsp;</td>
</tr>
<!-- 헤더 — 회사 CI(로고 있으면 로고, 없으면 회사명) + 솔루션 태그라인. 챗 포털 로그인 락업과 같은 구성 -->
<tr>
<td align="center" style="padding:30px 28px 0 28px;">
$header_content
<p style="margin:10px 0 0 0;font-size:12px;color:#8b95a1;">negotium B2B 구매협상 솔루션</p>
</td>
</tr>
<!-- 본문 -->
<tr>
<td style="padding:32px 28px 4px 28px;">
<h1 style="margin:0 0 10px 0;font-size:20px;font-weight:700;color:#111827;">협상 참여 요청</h1>
<p style="margin:0 0 24px 0;font-size:14px;line-height:1.7;color:#4b5563;">
<strong style="color:#111827;">$supplier_name</strong> 담당자님,<br>
아래 견적 건의 협상에 참여해 주세요.
<h1 style="margin:0 0 10px 0;font-size:20px;font-weight:700;letter-spacing:-0.4px;color:#191f28;">협상 참여 요청</h1>
<p style="margin:0 0 24px 0;font-size:14px;line-height:1.7;color:#4e5968;">
<strong style="color:#191f28;">$supplier_name</strong> 담당자님,<br>
$email_greeting
</p>
<!-- 견적 정보 카드 -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;border:1px solid #eef0f3;border-radius:10px;margin-bottom:28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;border:1px solid #e5e8eb;border-radius:12px;margin-bottom:28px;">
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;width:88px;">견적명</td>
<td style="padding:14px 16px;font-size:13px;color:#111827;font-weight:600;text-align:right;">$quotation_name</td>
<td style="padding:14px 16px;font-size:13px;color:#8b95a1;width:88px;">견적명</td>
<td style="padding:14px 16px;font-size:13px;color:#191f28;font-weight:600;text-align:right;">$quotation_name</td>
</tr>
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">견적번호</td>
<td style="padding:14px 16px;font-size:13px;color:#374151;text-align:right;border-top:1px solid #eef0f3;">$qt_number</td>
<td style="padding:14px 16px;font-size:13px;color:#8b95a1;border-top:1px solid #f2f4f6;">견적번호</td>
<td style="padding:14px 16px;font-size:13px;color:#333d4b;text-align:right;border-top:1px solid #f2f4f6;">$qt_number</td>
</tr>
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">협상 마감</td>
<td style="padding:14px 16px;font-size:13px;color:#dc2626;font-weight:700;text-align:right;border-top:1px solid #eef0f3;">$deadline</td>
<td style="padding:14px 16px;font-size:13px;color:#8b95a1;border-top:1px solid #f2f4f6;">협상 마감</td>
<td style="padding:14px 16px;font-size:13px;color:#e71c3b;font-weight:700;text-align:right;border-top:1px solid #f2f4f6;">$deadline</td>
</tr>
</table>
@ -41,9 +47,8 @@
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td align="center" bgcolor="#2563eb" style="border-radius:8px;">
<!-- TODO: 세션별 협상링크 연결 시 href 를 $$chat_url 로 복원 -->
<a href="https://nego.o2o.kr" style="display:inline-block;padding:14px 34px;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">협상 참여하기 →</a>
<td align="center" bgcolor="$brand_color" style="border-radius:12px;">
<a href="$chat_url" style="display:inline-block;padding:14px 34px;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:12px;">협상 참여하기 →</a>
</td>
</tr>
</table>
@ -56,15 +61,14 @@
<!-- 푸터 -->
<tr>
<td style="padding:18px 28px 26px 28px;">
<p style="margin:0;font-size:11px;line-height:1.7;color:#9ca3af;border-top:1px solid #f0f1f3;padding-top:16px;">
<p style="margin:0;font-size:11px;line-height:1.7;color:#8b95a1;border-top:1px solid #f2f4f6;padding-top:16px;">
버튼이 열리지 않으면 아래 링크를 복사해 접속하세요.<br>
<a href="https://nego.o2o.kr" style="color:#6b7280;word-break:break-all;">https://nego.o2o.kr</a>
<a href="$chat_url" style="color:#4e5968;word-break:break-all;">$chat_url</a>
</p>
</td>
</tr>
</table>
<p style="margin:16px 0 0 0;font-size:11px;color:#b0b4bb;font-family:sans-serif;">본 메일은 협상 견적 시스템에서 자동 발송되었습니다.</p>
</td>
</tr>
</table>

View File

@ -159,7 +159,8 @@ class LpsSyncService:
# product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵
parsed = []
for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, by_mall, created_at in rows:
for (code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url,
by_mall, sources, partial, created_at) in rows:
try:
iid = uuid.UUID(code)
except (ValueError, AttributeError, TypeError):
@ -170,7 +171,8 @@ class LpsSyncService:
"naver": (nv_name, nv_url),
"coupang": (cp_name, cp_url),
}.get((final_source or "").lower(), (None, None))
parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, by_mall, created_at))
parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url,
by_mall, sources, partial, created_at))
err, existing = await DB_SESSION_MNG.execute_lambda(
DBType.MAIN.value, DBWRType.DB_READ.value,
@ -180,7 +182,8 @@ class LpsSyncService:
return results
history_rows, latest_found = [], {} # latest_found: item_id → (created_at, price)
for item_id, outcome, final_lowest, final_source, src_name, src_url, by_mall, created_at in parsed:
for (item_id, outcome, final_lowest, final_source, src_name, src_url,
by_mall, sources, partial, created_at) in parsed:
if item_id not in existing:
results["skipped_unknown_item"] += 1
continue
@ -194,6 +197,10 @@ class LpsSyncService:
lp_name=(src_name or None) and src_name[:300],
lp_url=src_url or None,
by_mall=by_mall or None, # 몰별 스냅샷 그대로 미러링 — 몰별 성공/실패 표시용
# 몰별 확인 상태도 그대로 옮긴다. by_mall 에 없는 몰이 '없었다'인지 '못 봤다'인지는
# 이 값에만 있어, 없으면 화면이 두 경우를 구분할 방법이 없다.
sources=sources or None,
partial=bool(partial),
crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존
))
results["found" if found else "not_found"] += 1

View File

@ -95,12 +95,12 @@ class InvitesMixin:
async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list:
"""targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고
성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 발송에서 중단(EMAIL_NOT_CONFIGURED)."""
# 회사 브랜딩(초청 메일 헤더) 한 번 조회 — 견적당 동일.
settings = await DB_SESSION_MNG.execute_lambda(
# 회사명·브랜딩(메일 헤더·색·로고·문구) 한 번 조회 — 견적당 동일. 회사명은 헤더 기본값.
company_name, settings = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_company_settings(s, quotation.user_id),
lambda s: self.quotation_crud.get_company_brand(s, quotation.user_id),
)
email_header = (settings.get("branding") or {}).get("email_header")
branding = settings.get("branding") or {}
sent_ids = []
for sess, sp_name, email in targets:
subject, html, text = build_invite_email(
@ -109,7 +109,8 @@ class InvitesMixin:
qt_number=quotation.number,
end_time=quotation.end_time,
chat_url=self._session_chat_url(sess.session_id),
email_header=email_header,
company_name=company_name,
branding=branding,
)
try:
await send_email(email, subject, html, text)

View File

@ -180,7 +180,7 @@ class PricingMixin:
anchors[(iid, sid)] = (value, ap)
return anchors
async def get_target_breakdown(self, session_id: str, company_id=None) -> Res_TargetBreakdown:
async def get_target_breakdown(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_TargetBreakdown:
"""목표가 모달의 산정내역 응답.
저장된 목표가·앵커링가는 그대로 내려주고, 후보 목록은 _candidates 다시 계산한다.
@ -195,7 +195,7 @@ class PricingMixin:
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res
sess = got[0]
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
err_type, quotation = await self._fetch(sess.quotation_id, company_id, user_id, role)
if err_type != ErrorType.SUCCESS or quotation is None:
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res

View File

@ -23,9 +23,11 @@ from router.v1.quotation.protocol import (
class QueriesMixin:
async def _fetch(self, qt_id: uuid.UUID, company_id=None):
async def _fetch(self, qt_id: uuid.UUID, company_id=None, user_id=None, role=None):
"""견적 단건 조회. (ErrorType, quotation|None) 반환.
company_id 주어지면 회사 스코프(작성자 회사) 가드 남의 회사 견적은 NOT_FOUND. 내부/스케줄러 호출은 None."""
company_id 주어지면 회사 스코프(작성자 회사) 가드 남의 회사 견적은 NOT_FOUND. 내부/스케줄러 호출은 None.
user_id 주어지면 조회 게이팅 일반(USER) 본인 견적만, 남의 견적은 존재를 숨긴다(NOT_FOUND).
변경 액션(삭제·마감 ) 안내 문구가 필요해 user_id 없이 부르고 각자 ACCOUNT_FORBIDDEN 게이트를 탄다."""
err_type, quotation = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
@ -33,6 +35,8 @@ class QueriesMixin:
)
if err_type != ErrorType.SUCCESS or quotation is None:
return ErrorType.QUOTATION_NOT_FOUND, None
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
return ErrorType.QUOTATION_NOT_FOUND, None
return ErrorType.SUCCESS, quotation
async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
@ -90,9 +94,9 @@ class QueriesMixin:
res.total = total
return res
async def get_quotation(self, qt_id: str, company_id=None) -> Res_Quotation:
async def get_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
res = Res_Quotation()
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -121,9 +125,9 @@ class QueriesMixin:
res.result.SetResult(err_type)
return res
async def get_status(self, qt_id: str, company_id=None) -> Res_QuotationStatus:
async def get_status(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_QuotationStatus:
res = Res_QuotationStatus()
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -132,9 +136,9 @@ class QueriesMixin:
res.message = "ok"
return res
async def get_result(self, qt_id: str, company_id=None) -> Res_QuotationResult:
async def get_result(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_QuotationResult:
res = Res_QuotationResult()
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -147,10 +151,10 @@ class QueriesMixin:
res.result_count = 0
return res
async def list_sessions(self, qt_id: str, company_id=None) -> Res_QuotationSessions:
async def list_sessions(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_QuotationSessions:
res = Res_QuotationSessions()
qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid, company_id)
err_type, quotation = await self._fetch(qt_uuid, company_id, user_id, role)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -193,7 +197,7 @@ class QueriesMixin:
res.total = len(res.sessions)
return res
async def list_chats(self, session_id: str, company_id=None) -> Res_SessionChat:
async def list_chats(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_SessionChat:
res = Res_SessionChat()
sess_uuid = uuid.UUID(session_id)
res.session_id = sess_uuid
@ -208,7 +212,7 @@ class QueriesMixin:
if g_err != ErrorType.SUCCESS or got is None:
res.result.SetResult(g_err if g_err != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res
guard_err, _ = await self._fetch(got[0].quotation_id, company_id)
guard_err, _ = await self._fetch(got[0].quotation_id, company_id, user_id, role)
if guard_err != ErrorType.SUCCESS:
res.result.SetResult(guard_err)
return res
@ -242,10 +246,10 @@ class QueriesMixin:
]
return res
async def list_cards(self, qt_id: str, company_id=None) -> Res_QuotationCards:
async def list_cards(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_QuotationCards:
res = Res_QuotationCards()
qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid, company_id)
err_type, quotation = await self._fetch(qt_uuid, company_id, user_id, role)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -280,5 +284,37 @@ class QueriesMixin:
memo=memo,
)
)
# 봇은 버전 카드셋 밖에서도 카드를 고르므로(RL 런타임 미제한) 채팅의 사용 카드(chats.card_id)가
# 버전 목록에 없으면 화면·JSON 내보내기의 카드 매칭이 전부 null 이 된다 → 실사용 카드를 합쳐 내려준다.
# 부가 조회라 실패 시 버전 카드만으로 응답한다(목록의 참여수 집계와 같은 best-effort 패턴).
used_err, used_rows = await DB_SESSION_MNG.execute_lambda(
chats.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_used_cards(s, qt_uuid),
)
if used_err == ErrorType.SUCCESS:
seen = {c.session_card_id for c in cards}
for chat, card_pk, number, name, script, edit, condition, memo in used_rows:
# card_pk 미해석(카드 삭제 등)이면 건너뛴다 — 매칭할 카탈로그 정보가 없다.
if card_pk is None or card_pk in seen:
continue
seen.add(card_pk)
is_wild = chat.card_type == 2
cards.append(
QuotationCardData(
session_card_id=card_pk,
qt_id=quotation.qt_id,
nego_card_id=None if is_wild else card_pk,
wild_card_id=card_pk if is_wild else None,
type=chat.card_type,
number=number,
name=name,
script=script,
edit_script=edit,
condition=condition,
memo=memo,
)
)
res.cards = cards
return res

View File

@ -0,0 +1,74 @@
"""협상 초청 메일 미리보기(/v1/company/settings/email-preview) 테스트 — 회사 CI 슬롯 렌더 확인.
- 실제 발송(services/quotation/invites.py) 같은 build_invite_email 타므로 미리보기 검증 = 발송물 검증.
- 헤더는 회사 CI(logo_url·service_name) 그대로 쓰고 색은 솔루션 고정 메일 전용 설정은 인사 문구뿐.
- 색은 hex 통과한다(이상값이 style 깨거나 CSS 주입되는 방지).
"""
PREVIEW_URL = "/v1/company/settings/email-preview"
async def test_preview_requires_login(client):
"""검증: 토큰 없이 미리보기 호출.
기대결과: HTTP 401/403 으로 거부(로그인 필수)."""
r = await client.post(PREVIEW_URL, json={"branding": {}})
assert r.status_code in (401, 403)
async def test_preview_default_branding(client, auth_headers):
"""검증: branding 비운 채 미리보기 호출.
기대결과: 헤더는 회사명 텍스트(테스트사, NEGODATA 아님)·기본색(#3182f6)·기본 인사 문구로 렌더되고 제목이 채워진다."""
h = await auth_headers("previewer1")
r = await client.post(PREVIEW_URL, json={"branding": {}}, headers=h)
assert r.status_code == 200
body = r.json()
assert "협상 참여 요청" in body["subject"]
html = body["html"]
assert "테스트사" in html # conftest company_id 픽스처의 회사명
assert "NEGODATA" not in html
assert "#3182f6" in html
assert "아래 견적 건의 협상에 참여해 주세요." in html
assert "negotium B2B 구매협상 솔루션" in html # 발주사 CI 아래 솔루션 태그라인(고정)
async def test_preview_ci_branding(client, auth_headers):
"""검증: 회사 CI(서비스명·로고)와 인사 문구를 채워 미리보기 호출(브랜드 색을 넣어도 무시).
기대결과: 헤더는 로고 이미지(서비스명은 alt), 색은 회사 값과 무관하게 솔루션 기본색으로 렌더."""
h = await auth_headers("previewer2")
branding = {
"service_name": "아이좋아네고",
"logo_url": "https://cdn.example.com/ci.png",
"primary_color": "#f551a0", # 제거된 기능 — 값을 보내도 반영되지 않아야 한다
"email_greeting": "협상에 초대합니다.",
}
r = await client.post(PREVIEW_URL, json={"branding": branding}, headers=h)
assert r.status_code == 200
html = r.json()["html"]
assert '<img src="https://cdn.example.com/ci.png"' in html
assert 'alt="아이좋아네고"' in html
assert "#f551a0" not in html
assert "#3182f6" in html
assert "협상에 초대합니다." in html
async def test_preview_service_name_without_logo(client, auth_headers):
"""검증: 로고 없이 서비스명만 채워 미리보기 호출.
기대결과: 헤더는 서비스명 텍스트로 렌더되고 img 태그는 없다."""
h = await auth_headers("previewer3")
r = await client.post(PREVIEW_URL, json={"branding": {"service_name": "IMK 구매협상"}}, headers=h)
assert r.status_code == 200
html = r.json()["html"]
assert "IMK 구매협상" in html
assert "<img" not in html
async def test_preview_bad_color_falls_back(client, auth_headers):
"""검증: hex 가 아닌 primary_color(CSS 주입 꼴)로 미리보기 호출.
기대결과: 입력값은 HTML 실리지 않고 기본색(#3182f6)으로 렌더된다."""
h = await auth_headers("previewer4")
bad = "red;background-image:url(https://evil)"
r = await client.post(PREVIEW_URL, json={"branding": {"primary_color": bad}}, headers=h)
assert r.status_code == 200
html = r.json()["html"]
assert bad not in html
assert "#3182f6" in html

View File

@ -0,0 +1,33 @@
"""LPS 몰별 확인 상태 미러링 — '없었다''못 봤다'를 화면까지 나르는 계약.
by_mall **가격이 있는 몰만** 담는다. 그래서 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는
sources/partial 에만 있다. 값이 중간에서 빠지면 화면은 경우를 구분할 방법이 없다
(실제로 그래서 차단당한 몰이 '없음'으로 보였다 lps/docs/result-states.md).
"""
from crud.lps_sync_crud import _price_history
from router.v1.item.protocol import LowestPriceEntry
def test_read_contract_includes_source_state():
"""lps_db 읽기 계약에서 이 컬럼이 빠지면 화면까지 갈 값이 사라진다."""
cols = {c.name for c in _price_history.columns}
assert {"sources", "partial"} <= cols, cols
assert "by_mall" in cols # 함께 읽어야 '가격 있는 몰'과 '확인한 몰'을 대조할 수 있다
def test_api_entry_exposes_state_with_safe_defaults():
"""이 컬럼 추가 이전 이력(값 없음)도 화면이 깨지지 않아야 한다."""
e = LowestPriceEntry()
assert e.sources is None
assert e.partial is False, "기본이 True 면 정상 결과가 '일부 확인 못함'으로 보인다"
def test_api_entry_carries_source_state():
e = LowestPriceEntry(
lp_price=9000, success_yn=True, partial=True,
sources={"naver": {"state": "matched", "count": 40},
"coupang": {"state": "blocked", "error": "차단"}},
)
assert e.partial is True
assert e.sources["coupang"]["state"] == "blocked"

View File

@ -0,0 +1,142 @@
"""견적 조회 게이팅 + 사용 카드 매칭 보강.
- 조회 게이팅: 일반(USER) 본인 견적만 목록은 서버가 강제 필터, 상세는 남의 견적의 존재 자체를
숨긴다(QUOTATION_NOT_FOUND). OWNER 이상은 회사 전체 조회. 판정은 common.authz.is_owner_or_admin 공용.
- 사용 카드: 봇은 버전 카드셋 밖에서도 카드를 고르므로(RL 런타임 미제한) 버전 목록에 없는
실사용 카드(chats.card_id) /cards 응답에 합쳐 내려준다 협상로그 화면·JSON 내보내기의
카드 매칭 실패(card=null) 복구.
"""
import uuid
from datetime import datetime
from sqlalchemy import text
from common.enums import ChatSender, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole
from crud.quotation_crud import QuotationCRUD
from services.quotation import QuotationService
PAST = datetime(2020, 1, 1)
# ===== 목록 — HTTP e2e: 같은 회사, 담당자 2명 =====
async def test_list_forced_to_own_for_user_role(client, auth_headers, db_engine):
"""검증: 같은 회사 담당자 A/B 가 견적을 하나씩 가진 상태에서 일반유저 B 가 목록 조회(mine 미지정).
기대결과: B 본인 견적만 내려온다 서버가 mine 강제해 A 견적은 목록에서 제외."""
ha = await auth_headers("read_user_a")
hb = await auth_headers("read_user_b")
qt_a = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_a"), number="READ-A")
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_b"), number="READ-B")
numbers = [q["number"] for q in (await client.get("/v1/quotation/list", headers=hb)).json()["quotations"]]
assert numbers == ["READ-B"]
# 소유자 A 본인은 당연히 자기 견적을 본다(대조군)
assert (await client.get(f"/v1/quotation/{qt_a}", headers=ha)).json()["result"]["success"] is True
async def test_list_full_company_for_owner_role(client, auth_headers, db_engine):
"""검증: 최고관리자(OWNER)가 목록 조회(mine 미지정).
기대결과: 회사 전체 다른 담당자들 견적까지 모두 내려온다."""
await auth_headers("read_user_c")
hadmin = await auth_headers("read_admin", role=UserRole.OWNER.value)
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_user_c"), number="READ-C")
await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_admin"), number="READ-D")
numbers = {q["number"] for q in (await client.get("/v1/quotation/list", headers=hadmin)).json()["quotations"]}
assert numbers == {"READ-C", "READ-D"}
# ===== 상세 — HTTP e2e: 남의 견적 =====
async def test_detail_hidden_from_other_user(client, auth_headers, db_engine):
"""검증: A 의 견적 상세(/{qt_id}·/sessions·/cards)를 같은 회사 일반유저 B 가 조회.
기대결과: 모두 거부 존재를 숨기는 QUOTATION_NOT_FOUND(권한 코드가 아니라 미존재로 응답)."""
await auth_headers("read_owner")
hb = await auth_headers("read_other")
qt = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_owner"), number="READ-HIDE")
for path in (f"/v1/quotation/{qt}", f"/v1/quotation/{qt}/sessions", f"/v1/quotation/{qt}/cards"):
body = (await client.get(path, headers=hb)).json()
assert body["result"]["success"] is False
assert body["result"]["code"] == ErrorType.QUOTATION_NOT_FOUND.value
async def test_detail_visible_to_owner_role(client, auth_headers, db_engine):
"""검증: A 의 견적 상세를 같은 회사 최고관리자(OWNER)가 조회.
기대결과: 성공 소유자가 아니어도 OWNER 조회 가능."""
await auth_headers("read_owner2")
hadmin = await auth_headers("read_admin2", role=UserRole.OWNER.value)
qt = await _seed_quotation(db_engine, user_id=await _user_id(db_engine, "read_owner2"), number="READ-SHOW")
body = (await client.get(f"/v1/quotation/{qt}", headers=hadmin)).json()
assert body["result"]["success"] is True
assert body["quotation"]["number"] == "READ-SHOW"
# ===== 사용 카드 — 서비스 직접: 버전 밖 카드 매칭 =====
async def test_list_cards_includes_used_card_outside_version(db_engine):
"""검증: 버전 카드셋엔 없는 카드를 채팅에서 사용(chats.card_used_yn=true, card_id=카드 PK)한 견적의 카드 조회.
기대결과: 응답에 카드가 session_card_id=카드 PK 포함 화면·내보내기 매칭이 성립한다."""
user = uuid.uuid4()
qt = await _seed_quotation(db_engine, user_id=user, number="CARD-USED")
card = uuid.uuid4()
session = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO nego_cards (nego_card_id, user_id, name, number, script, usage_type) "
"VALUES (:c, :u, '실사용카드', 'NGC-X1', '멘트', 1)"),
{"c": card, "u": user},
)
await conn.execute(
text(
"INSERT INTO sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, end_time) VALUES "
"(:sid, :qt, :item, :sup, 'CARD-USED', 1, :type, 0, :st, :t)"
),
{"sid": session, "qt": qt, "item": uuid.uuid4(), "sup": uuid.uuid4(),
"type": QuotationType.REQUOTE.value, "st": SessionStatus.DONE.value, "t": PAST},
)
await conn.execute(
text(
"INSERT INTO chats (chat_id, session_id, card_id, seq, sender, target_price, card_used_yn, card_type) "
"VALUES (:cid, :sid, :card, 1, :sender, 0, true, 1)"
),
{"cid": uuid.uuid4(), "sid": session, "card": card, "sender": ChatSender.BOT.value},
)
res = await QuotationService(QuotationCRUD()).list_cards(str(qt))
assert res.result.success is True
got = {str(c.session_card_id): c for c in res.cards}
assert str(card) in got
assert got[str(card)].number == "NGC-X1"
assert got[str(card)].nego_card_id == card # nego 카드로 분류(type=1)
# ===== 헬퍼 =====
async def _user_id(engine, login_id) -> uuid.UUID:
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT user_id FROM users WHERE id = :i"), {"i": login_id}
)).scalar_one()
async def _seed_quotation(engine, *, user_id, number):
"""견적 1건 시드(조회 게이팅 확인용 — 상태는 무관하므로 CLOSED 고정)."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적', :number, :type, :status, "
" 1, 0, :t, :t, false)"
),
{
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
"version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value,
"status": QuotationStatus.CLOSED.value, "t": PAST,
},
)
return qt_id

Binary file not shown.

View File

@ -25,8 +25,10 @@ import type {
import type {
HTTPValidationError,
ReqPreviewInviteEmail,
ReqUpdateCompanySettings,
ResCompanySettings
ResCompanySettings,
ResPreviewInviteEmail
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
@ -191,4 +193,68 @@ export const useUpdateSettings = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* @summary
*/
export const previewInviteEmail = (
reqPreviewInviteEmail: ReqPreviewInviteEmail,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResPreviewInviteEmail>(
{url: `/v1/company/settings/email-preview`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqPreviewInviteEmail, signal
},
options);
}
export const getPreviewInviteEmailMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof previewInviteEmail>>, TError,{data: ReqPreviewInviteEmail}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof previewInviteEmail>>, TError,{data: ReqPreviewInviteEmail}, TContext> => {
const mutationKey = ['previewInviteEmail'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof previewInviteEmail>>, {data: ReqPreviewInviteEmail}> = (props) => {
const {data} = props ?? {};
return previewInviteEmail(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PreviewInviteEmailMutationResult = NonNullable<Awaited<ReturnType<typeof previewInviteEmail>>>
export type PreviewInviteEmailMutationBody = ReqPreviewInviteEmail
export type PreviewInviteEmailMutationError = void | HTTPValidationError
/**
* @summary
*/
export const usePreviewInviteEmail = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof previewInviteEmail>>, TError,{data: ReqPreviewInviteEmail}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof previewInviteEmail>>,
TError,
{data: ReqPreviewInviteEmail},
TContext
> => {
const mutationOptions = getPreviewInviteEmailMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -92,6 +92,8 @@ export * from './lowestPriceEntryFailReason';
export * from './lowestPriceEntryLpName';
export * from './lowestPriceEntryLpPrice';
export * from './lowestPriceEntryLpUrl';
export * from './lowestPriceEntrySources';
export * from './lowestPriceEntrySourcesAnyOf';
export * from './notificationData';
export * from './notificationDataCreatedAt';
export * from './notificationDataData';
@ -201,6 +203,8 @@ export * from './reqCreateSupplierManagerEmail';
export * from './reqCreateSupplierManagerName';
export * from './reqCreateSupplierTotalRevenue';
export * from './reqLogin';
export * from './reqPreviewInviteEmail';
export * from './reqPreviewInviteEmailBranding';
export * from './reqRegenerateQuotation';
export * from './reqRegenerateQuotationCardIds';
export * from './reqRegenerateQuotationDoneCeilingRate';
@ -342,6 +346,8 @@ export * from './resNotificationRead';
export * from './resNotificationReadMsg';
export * from './resNotifySessions';
export * from './resNotifySessionsMsg';
export * from './resPreviewInviteEmail';
export * from './resPreviewInviteEmailMsg';
export * from './resQuotation';
export * from './resQuotationCards';
export * from './resQuotationCardsMsg';

View File

@ -9,6 +9,7 @@ import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason';
import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName';
import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl';
import type { LowestPriceEntryByMall } from './lowestPriceEntryByMall';
import type { LowestPriceEntrySources } from './lowestPriceEntrySources';
import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime';
/**
@ -22,5 +23,7 @@ export interface LowestPriceEntry {
lp_name?: LowestPriceEntryLpName;
lp_url?: LowestPriceEntryLpUrl;
by_mall?: LowestPriceEntryByMall;
sources?: LowestPriceEntrySources;
partial?: boolean;
crawl_end_time?: LowestPriceEntryCrawlEndTime;
}

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { LowestPriceEntrySourcesAnyOf } from './lowestPriceEntrySourcesAnyOf';
export type LowestPriceEntrySources = LowestPriceEntrySourcesAnyOf | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type LowestPriceEntrySourcesAnyOf = { [key: string]: unknown };

View File

@ -0,0 +1,11 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqPreviewInviteEmailBranding } from './reqPreviewInviteEmailBranding';
export interface ReqPreviewInviteEmail {
branding?: ReqPreviewInviteEmailBranding;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqPreviewInviteEmailBranding = { [key: string]: unknown };

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResPreviewInviteEmailMsg } from './resPreviewInviteEmailMsg';
export interface ResPreviewInviteEmail {
result?: ErrorInfo;
msg?: ResPreviewInviteEmailMsg;
subject?: string;
html?: string;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ResPreviewInviteEmailMsg = string | null;

View File

@ -21,18 +21,6 @@ import NotificationsPage from '../pages/notifications';
import OnboardingPage from '../pages/onboarding';
export const router = createBrowserRouter([
// dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과
// 내부 import()가 통째로 트리셰이킹되어 번들/라우트에 포함되지 않는다.
...(import.meta.env.DEV
? [
{
path: 'dev/design',
lazy: async () => ({
Component: (await import('../pages/dev/dev-design')).default,
}),
},
]
: []),
{
index: true,
loader: () => redirect('/dashboard'),

View File

@ -180,11 +180,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{branding.logoUrl ? (
<img src={branding.logoUrl} alt={branding.serviceName} className="h-4 max-w-24 object-contain" />
) : (
<span
aria-hidden
className="size-4 rounded-[5px] bg-primary"
style={branding.primaryColor ? { backgroundColor: branding.primaryColor } : undefined}
/>
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
)}
{branding.serviceName}
</Typography>
@ -291,7 +287,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{/* Main Container Wrapper */}
<div
className={cn(
'flex-1 flex flex-col transition-all duration-300',
// min-w-0: flex 아이템은 기본 min-width:auto 라 넓은 콘텐츠(다컬럼 표·차트)가
// 래퍼를 뷰포트 밖으로 밀어낸다 — 이걸 끊어야 표 내부 overflow-x-auto 가 스크롤로 받는다.
'flex-1 min-w-0 flex flex-col transition-all duration-300',
// 모바일: 사이드바가 오버레이라 패딩 없음 / 데스크톱: 사이드바 폭만큼 확보
isSidebarOpen ? 'md:pl-60' : 'md:pl-16'
)}
@ -585,7 +583,8 @@ function NavItem({
function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
return (
<div className="flex items-center gap-3 md:gap-4">
<Typography as="div" variant="caption" className="hidden lg:block">
{/* 기준일시·관리자는 xl 부터 — lg 구간(1024~1280)은 우측 1fr 이 ~240px 라 글자가 세로로 깨진다. */}
<Typography as="div" variant="caption" className="hidden xl:block whitespace-nowrap">
: <span className="font-semibold text-foreground">{today}</span>
</Typography>
@ -603,8 +602,8 @@ function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
</Typography>
</div>
<div className="h-4 w-px bg-border hidden lg:block" />
<div className="hidden lg:flex items-center gap-2">
<div className="h-4 w-px bg-border hidden xl:block" />
<div className="hidden xl:flex items-center gap-2 whitespace-nowrap">
<Typography as="span" variant="caption">:</Typography>
<Typography as="span" variant="caption" className="font-semibold text-foreground">
{user?.name} ({user?.loginId})

View File

@ -236,7 +236,7 @@ const ZONE_STYLE: Record<NegotiationZoneTone, { marker: string; active: string;
marker: 'bg-primary',
active: 'bg-primary/10',
text: 'text-primary',
track: '#2563eb',
track: '#5E6AD2',
},
};

View File

@ -0,0 +1,332 @@
import { useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { ImagePlus, X, Loader2 } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
import { useScrollLock } from '@/lib/useScrollLock';
import { uploadItemImage, updateItem } from '@/api/generated/item/item';
import { useAuthStore, canManage } from '@/stores/auth';
import type { Product } from '../types';
// 로컬 이미지 일괄 업로드 — 파일명(확장자 제외)을 상품코드로 매칭해 업로드하고 상품에 연결한다.
// 엑셀 양식과 무관한 독립 흐름: 기존 단건 업로드(/v1/item/image)와 상품 수정(image_url)을 파일별로 반복한다.
// (협력사·카드 엑셀과 같은 프론트 루프 패턴 — 신규 API 없음)
// 상품 등록 폼이 이미지 미입력 시 넣는 기본(placeholder) 이미지 — 실제 이미지로 치지 않는다.
const PLACEHOLDER_IMAGE_MARK = 'images.unsplash.com/photo-1593941707882';
type RowIssue =
| 'unmatched' // 파일명과 일치하는 상품코드 없음
| 'dup_code' // 같은 코드의 상품이 여러 건 — 어느 상품인지 특정 불가
| 'dup_file' // 같은 코드의 파일이 여러 개 — 앞 파일만 유효
| 'forbidden'; // 남의 상품(수정권한 없음 — 본인∪최고관리자만)
type RowState = 'ready' | 'skip' | 'uploading' | 'done' | 'failed';
type MatchRow = {
file: File;
base: string; // 파일명에서 확장자를 뗀 값 = 상품코드 후보
product?: Product;
issue?: RowIssue;
hasImage: boolean; // 기존 이미지 보유(placeholder 제외) — 덮어쓰기 옵션 판정용
state: RowState;
reason?: string; // failed 사유
};
type Phase = 'select' | 'running' | 'done';
export function ImageBulkUploadModal({
open,
loadProducts,
onClose,
}: {
open: boolean;
loadProducts: () => Promise<Product[]>; // 전체 상품(페이지 밖 포함) — 매칭·권한 판정용
onClose: () => void;
}) {
useScrollLock(open);
const queryClient = useQueryClient();
const myUserId = useAuthStore((s) => s.user?.userId);
const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role));
const [rows, setRows] = useState<MatchRow[]>([]);
const [phase, setPhase] = useState<Phase>('select');
const [overwrite, setOverwrite] = useState(true); // 대부분 placeholder 이미지라 기본 덮어쓰기
const [matching, setMatching] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
// 파일 선택/드롭 → 전체 상품을 받아 파일명=상품코드 매칭표를 만든다(업로드는 아직 안 한다).
const handleFiles = async (files: FileList | File[]) => {
const picked = [...files].filter((f) => f.type.startsWith('image/'));
if (picked.length === 0) {
showToast('이미지 파일이 없습니다. (jpg/png 등 이미지만 지원)', 'error');
return;
}
setMatching(true);
try {
const products = await loadProducts();
setRows(buildRows(picked, products, myUserId, isSuperAdmin));
setPhase('select');
} catch (err) {
showToast(err instanceof Error ? err.message : '상품 목록 조회에 실패했습니다.', 'error');
} finally {
setMatching(false);
}
};
const uploadTargets = rows.filter((r) => isUploadable(r, overwrite));
const skippedByImage = rows.filter((r) => !r.issue && r.product && !overwrite && r.hasImage);
// 업로드 실행 — 파일별 순차: 이미지 업로드 → 받은 URL 을 상품 수정으로 저장. 실패해도 멈추지 않는다.
const handleRun = async () => {
setPhase('running');
let ok = 0;
let fail = 0;
for (const row of rows) {
if (!isUploadable(row, overwrite)) continue;
setRowState(setRows, row.file, { state: 'uploading' });
try {
const up = await uploadItemImage({ file: row.file as unknown as string });
if (up.result?.success === false) throw new Error(up.result.desc || '이미지 업로드 실패');
if (!up.image_url) throw new Error('업로드 응답에 URL 이 없습니다.');
const res = await updateItem(row.product!.item_id ?? '', { image_url: up.image_url });
if (res.result?.success === false) throw new Error(res.result.desc || '상품 수정 거부');
setRowState(setRows, row.file, { state: 'done' });
ok += 1;
} catch (err) {
setRowState(setRows, row.file, {
state: 'failed',
reason: err instanceof Error ? err.message : '업로드 실패',
});
fail += 1;
}
}
await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] });
setPhase('done');
showToast(
fail === 0 ? `${ok}개 상품 이미지가 연결되었습니다.` : `${ok}개 성공 · ${fail}개 실패`,
fail === 0 ? 'success' : 'error',
);
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-3xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono">
{/* 헤더 */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<Typography variant="h3"> </Typography>
<button
onClick={onClose}
disabled={phase === 'running'}
className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<X size={18} />
</button>
</div>
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground mt-3">
( ) <Typography as="span" variant="small" className="text-[11px] font-bold text-foreground"></Typography>
. : <Typography as="span" variant="small" className="text-[11px] font-semibold text-foreground">ABC-001.jpg ABC-001</Typography>
</Typography>
{/* 파일 선택 존 */}
<div
onClick={() => phase !== 'running' && fileRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (phase !== 'running') handleFiles(e.dataTransfer.files);
}}
className={`mt-4 rounded-md border-2 border-dashed border-border p-6 text-center transition-colors ${
phase === 'running' ? 'opacity-50' : 'cursor-pointer hover:border-primary hover:bg-primary/5'
}`}
>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) handleFiles(e.target.files);
e.target.value = ''; // 같은 파일 재선택 허용
}}
/>
{matching ? (
<Typography as="div" variant="small" className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
</Typography>
) : (
<>
<ImagePlus size={20} className="mx-auto text-muted-foreground" />
<Typography as="p" variant="small" className="mt-2 text-xs text-muted-foreground">
( )
</Typography>
</>
)}
</div>
{/* 옵션 + 매칭표 */}
{rows.length > 0 && (
<>
<label className="mt-4 flex items-center gap-2 cursor-pointer w-fit">
<input
type="checkbox"
checked={overwrite}
disabled={phase === 'running'}
onChange={(e) => setOverwrite(e.target.checked)}
className="h-3.5 w-3.5 rounded border-border accent-primary cursor-pointer"
/>
<Typography as="span" variant="small" className="text-xs">
</Typography>
</label>
<div className="mt-3 border border-border rounded-md overflow-hidden">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8.5rem] gap-x-3 px-3 py-2 bg-muted/40 border-b border-border">
<Typography as="span" variant="mono" className="text-[10px] text-muted-foreground"></Typography>
<Typography as="span" variant="mono" className="text-[10px] text-muted-foreground"> </Typography>
<Typography as="span" variant="mono" className="text-[10px] text-muted-foreground text-right"></Typography>
</div>
<div className="max-h-64 overflow-y-auto divide-y divide-border">
{rows.map((r, i) => (
<div key={`${r.file.name}-${i}`} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8.5rem] gap-x-3 px-3 py-1.5 items-center">
<Typography as="span" variant="small" className="text-[11px] truncate" title={r.file.name}>
{r.file.name}
</Typography>
<Typography as="span" variant="small" className="text-[11px] truncate text-muted-foreground" title={r.product?.name ?? ''}>
{r.product ? r.product.name : '—'}
</Typography>
<span className="text-right">{renderStatus(r, overwrite)}</span>
</div>
))}
</div>
</div>
{/* 요약 + 실행 */}
<div className="mt-4 flex items-center justify-between gap-3">
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
{uploadTargets.length}
{skippedByImage.length > 0 && ` · 기존 이미지로 건너뜀 ${skippedByImage.length}`}
{rows.filter((r) => r.issue).length > 0 && ` · 제외 ${rows.filter((r) => r.issue).length}`}
</Typography>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={phase === 'running'}>
{phase === 'done' ? '닫기' : '취소'}
</Button>
{phase !== 'done' && (
<Button size="sm" onClick={handleRun} disabled={phase === 'running' || uploadTargets.length === 0}>
{phase === 'running' ? (
<>
<Loader2 size={13} className="animate-spin" />
</>
) : (
`업로드 시작 (${uploadTargets.length})`
)}
</Button>
)}
</div>
</div>
</>
)}
</div>
</div>
);
}
// 파일 → 상품 매칭표. 코드 비교는 공백 제거 + 대소문자 무시(엑셀·수기 등록 코드 표기가 섞여 있어서).
function buildRows(
files: File[],
products: Product[],
myUserId: string | undefined,
isSuperAdmin: boolean,
): MatchRow[] {
const byCode = new Map<string, Product[]>();
for (const p of products) {
const code = String(p.code ?? '').trim().toLowerCase();
if (!code) continue;
byCode.set(code, [...(byCode.get(code) ?? []), p]);
}
const seenBases = new Set<string>();
return files.map((file) => {
const base = file.name.replace(/\.[^.]+$/, '').trim();
const key = base.toLowerCase();
const matches = byCode.get(key) ?? [];
const product = matches.length === 1 ? matches[0] : undefined;
let issue: RowIssue | undefined;
if (matches.length === 0) issue = 'unmatched';
else if (matches.length > 1) issue = 'dup_code';
else if (seenBases.has(key)) issue = 'dup_file';
else if (!isSuperAdmin && product?.user_id !== myUserId) issue = 'forbidden';
seenBases.add(key);
const hasImage = !!product?.image_url && !product.image_url.includes(PLACEHOLDER_IMAGE_MARK);
return { file, base, product, issue, hasImage, state: issue ? 'skip' : 'ready' } as MatchRow;
});
}
function isUploadable(row: MatchRow, overwrite: boolean): boolean {
if (row.issue || !row.product) return false;
if (!overwrite && row.hasImage) return false;
return row.state === 'ready' || row.state === 'uploading' || row.state === 'failed';
}
// 특정 파일 행의 상태만 갱신(불변 업데이트).
function setRowState(
setRows: React.Dispatch<React.SetStateAction<MatchRow[]>>,
file: File,
patch: Partial<Pick<MatchRow, 'state' | 'reason'>>,
) {
setRows((prev) => prev.map((r) => (r.file === file ? { ...r, ...patch } : r)));
}
const ISSUE_LABEL: Record<RowIssue, string> = {
unmatched: '코드 미매칭',
dup_code: '코드 중복',
dup_file: '파일명 중복',
forbidden: '권한 없음',
};
function renderStatus(row: MatchRow, overwrite: boolean) {
if (row.issue) {
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-rose-500/10 text-rose-600 dark:text-rose-400">
{ISSUE_LABEL[row.issue]}
</Typography>
);
}
if (row.state === 'uploading') {
return (
<Typography as="span" variant="small" className="text-[10px] inline-flex items-center gap-1 text-muted-foreground">
<Loader2 size={10} className="animate-spin" />
</Typography>
);
}
if (row.state === 'done') {
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
</Typography>
);
}
if (row.state === 'failed') {
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-rose-500/10 text-rose-600 dark:text-rose-400" title={row.reason}>
</Typography>
);
}
if (!overwrite && row.hasImage) {
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
·
</Typography>
);
}
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{row.hasImage ? '덮어쓰기' : '업로드 대기'}
</Typography>
);
}

View File

@ -9,6 +9,7 @@ import { Typography } from '@/components/ui/typography';
import { useScrollLock } from '@/lib/useScrollLock';
import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item';
import type { LowestPriceEntryByMall } from '@/api/generated/model/lowestPriceEntryByMall';
import type { LowestPriceEntrySources } from '@/api/generated/model/lowestPriceEntrySources';
import type { Product } from '../types';
type PriceUpdateModalProps = {
@ -35,9 +36,24 @@ const SOURCES = [
{ key: 'coupang', label: '쿠팡' },
] as const;
// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. 값이 없으면 그 몰은 못 찾은 것.
// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것.
type MallPrices = Record<string, number>;
// 값이 없는 몰은 두 경우다 — '거기엔 없었다'와 '거기를 못 봤다'. 뜻이 정반대라 같이 보이면 안 된다.
// 사용자가 할 수 있는 건 '쓴다 / 다시 시도 / 넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면
// 같은 표기로 접는다(운영자 화면 lps-admin 은 7상태를 그대로 본다).
// matched → 가격
// no_match · empty → '' (결론은 '이 몰엔 없다')
// blocked · env_blocked · unavailable → '확인 못함' (행동은 '나중에 다시' 하나뿐)
// 상태 정의는 lps/docs/result-states.md.
const CONFIRMED_STATES = new Set(['matched', 'no_match', 'empty', 'skipped']);
/** 그 몰을 확인하지 못했는가(=가격 자리에 '' 대신 '확인 못함'을 써야 하는가). */
const isUnconfirmed = (sources: LowestPriceEntrySources | undefined, source: string): boolean => {
const state = (sources as Record<string, { state?: string }> | null | undefined)?.[source]?.state;
return !!state && !CONFIRMED_STATES.has(state);
};
const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => {
const out: MallPrices = {};
for (const entry of byMall ?? []) {
@ -52,8 +68,8 @@ const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => {
// 상품 1건의 처리 상태(화면 전체 상태와 구분해 ItemState 로 둔다).
type ItemState =
| { kind: 'pending' } // 접수됨 — 결과 대기
| { kind: 'done'; price: number; malls: MallPrices }
| { kind: 'notfound'; malls: MallPrices } // 검색은 됐으나 같은 상품이 없었음(기존 값 유지)
| { kind: 'done'; price: number; malls: MallPrices; unconfirmed?: string[] }
| { kind: 'notfound'; malls: MallPrices; unconfirmed?: string[] } // 검색은 됐으나 같은 상품이 없었음(기존 값 유지)
| { kind: 'failed'; reason?: string } // 접수 실패 또는 검색 실패(사이트 차단) — 재시도 대상
| { kind: 'timeout' } // 폴링 상한 초과 — 서버는 계속 검색 중
| { kind: 'stopped' }; // 사용자가 지켜보기를 중단 — 서버는 계속 검색 중
@ -249,13 +265,17 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
if (!fresh) continue;
pending.delete(id);
const malls = toMallPrices(fresh.by_mall ?? null);
// 가격 자리에 ''(없음) 대신 '확인 못함'을 써야 하는 몰들
const unconfirmed = SOURCES.map((x) => x.key).filter((m) => isUnconfirmed(fresh.sources, m));
if (fresh.success_yn && fresh.lp_price != null) {
found += 1;
const prev = prevPriceOf(id);
if (prev <= 0 || fresh.lp_price < prev) succeeded.add(id); // 값이 실제로 갱신된 행
// 값이 실제로 갱신된 행. 단 **못 본 몰이 있으면 완료로 치지 않는다** —
// 그 몰에 더 싼 값이 있었을 수 있어, 다음 '다시 검색'의 기본 대상으로 남겨둔다.
if ((prev <= 0 || fresh.lp_price < prev) && unconfirmed.length === 0) succeeded.add(id);
// by_mall 이 비어 오는 옛 이력 대비 — 최소한 대표 최저가는 보이도록 폴백을 채운다.
if (Object.keys(malls).length === 0) malls.etc = fresh.lp_price;
setItem(id, { kind: 'done', price: fresh.lp_price, malls });
setItem(id, { kind: 'done', price: fresh.lp_price, malls, unconfirmed });
} else if (fresh.fail_reason === 'error') {
// 검색 자체가 실패한 경우(모든 소스 차단). '못 찾음'과 뜻이 완전히 다르다 —
// 상품이 없다는 게 아니라 **확인을 못 했다**는 뜻이라, 재시도 대상으로 분류한다.
@ -264,7 +284,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
// 문구는 '탐색 실패 사유: {reason}' 형태로 붙는다 — 사유만 간결하게 담는다.
setItem(id, { kind: 'failed', reason: '사이트 차단으로 가격을 확인하지 못했습니다 (잠시 후 다시 시도해 주세요)' });
} else {
setItem(id, { kind: 'notfound', malls });
setItem(id, { kind: 'notfound', malls, unconfirmed });
}
} catch {
/* 일시 오류는 다음 tick 재시도 */
@ -311,7 +331,13 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
const prices = Object.values(malls);
const best = prices.length > 0 ? Math.min(...prices) : null;
const price = malls[source];
if (price === undefined) return <span className="text-muted-foreground"></span>;
if (price === undefined) {
// 안 본 걸 '없음()'으로 보여주면 사용자는 '이 몰엔 더 싼 게 없다'로 읽는다 — 사실이 아니다.
const unseen = (s.kind === 'done' || s.kind === 'notfound') && s.unconfirmed?.includes(source);
return unseen
? <span className="text-amber-600" title="사이트 차단 등으로 이 몰의 가격을 확인하지 못했습니다"> </span>
: <span className="text-muted-foreground"></span>;
}
const isBest = price === best;
return (
<span className={isBest ? 'font-semibold text-foreground' : 'text-muted-foreground'}>
@ -330,6 +356,16 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
switch (s.kind) {
case 'done':
case 'notfound':
// 못 본 몰이 있으면 '변동 없음'이라 말하면 안 된다 — 그 몰에 더 싼 값이 있었을 수 있어
// 이 결과는 최종이 아니다. 사용자에게 필요한 건 어느 몰이 왜 막혔는지가 아니라
// '결과가 완전하지 않다'는 사실 하나다(재시도할 이유가 생긴다).
if (s.unconfirmed?.length) {
return (
<span className="text-amber-600" title={`${s.unconfirmed.join(', ')}을(를) 확인하지 못해 최종 결과가 아닙니다`}>
</span>
);
}
// 검색은 정상 수행됐으나 기존보다 낮은 가격이 없었음(미발견 포함) — 값이 안 바뀐 상태.
return <span className="text-muted-foreground"> </span>;
case 'failed':

View File

@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAuthStore, canManage } from '@/stores/auth';
import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { useCompanySettings, useLabels, useHiddenFields, useVatMode } from '@/features/settings/useCompanySettings';
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
import { ItemSuppliersManager } from './ItemSuppliersManager';
import { NewItemSuppliersPicker, type PickedSupplier } from './NewItemSuppliersPicker';
@ -141,6 +141,7 @@ export function ProductFormSheet({
const label = useLabels(); // 회사 설정 용어
const isHidden = useHiddenFields();
const vatUnified = useVatMode() === 'unified_excluded'; // 부가세 전체 통일(VAT 별도) 회사
// 숨김 필드는 DOM 에서 제거하지 않고 감추기만 한다 — 수정 시 기존 값이 그대로 유지·전송되도록.
const hideCls = (key: string, base = 'space-y-1') => (isHidden(key) ? `${base} hidden` : base);
const hideCls2 = (hidden: boolean, base: string) => (hidden ? `${base} hidden` : base);
@ -178,7 +179,8 @@ export function ProductFormSheet({
delivery_type: v.shippingType,
moq: v.moq,
lead_time: v.leadTime,
vat_yn: v.vatYn,
// 부가세를 관리하지 않는 회사(전체 통일 모드·숨김)는 값을 쓰지 않는다 — 스위치 기본값(포함)이 몰래 저장되는 것 방지.
vat_yn: isHidden('vat_yn') ? undefined : v.vatYn,
delivery_fee_yn: v.deliveryFeeYn,
internet_lowest_price_yn: v.internetLowestPriceYn,
internet_lowest_price: v.minPrice,
@ -293,6 +295,12 @@ export function ProductFormSheet({
</div>
)}
{/* 전체 통일 회사는 가격 입력 기준을 폼에서 못 박는다 — 상품별 VAT 입력이 없어 달리 알 길이 없다. */}
{vatUnified && (
<Typography as="p" variant="caption" className="text-muted-foreground -mb-2">
VAT () .
</Typography>
)}
<div className="grid grid-cols-2 gap-4">
{/* Price */}
<div className={hideCls('price')}>

View File

@ -111,6 +111,14 @@ export function ProductTable({
cellClassName: 'font-mono font-bold text-foreground',
cell: (prod) => `${(prod.price || 0).toLocaleString()}`,
},
{
field: 'purchase_price',
header: label('item.purchase_price'),
align: 'right',
cellClassName: 'font-mono font-bold text-foreground',
cell: (prod) =>
prod.purchase_price != null ? `${Number(prod.purchase_price).toLocaleString()}` : '-',
},
{
field: 'internet_lowest_price',
header: label('item.internet_lowest_price'),

View File

@ -6,7 +6,7 @@ import { useListItems, useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types';
import { useLabels } from '@/features/settings/useCompanySettings';
import { useLabels, useVatMode } from '@/features/settings/useCompanySettings';
import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
@ -117,6 +117,7 @@ export function QuotationCreateModal({
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const vatUnified = useVatMode() === 'unified_excluded'; // 부가세 전체 통일 — 목표가 입력 기준 안내
const supplyTypeBySupplier = useMemo(() => {
const m = new Map<string, number>();
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
@ -556,6 +557,7 @@ export function QuotationCreateModal({
{autoTarget != null
? '위에서 선택한 후보값이 목표가(1순위)로 입력됩니다. 수정 가능.'
: '자동 산출값이 없어 직접 입력이 필요합니다.'}
{vatUnified && ' 금액은 VAT 별도(제외) 기준입니다.'}
</Typography>
{!targetReady && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">

View File

@ -8,12 +8,12 @@ import SlateRenderer from '@/components/SlateRenderer';
import { Typography } from '@/components/ui/typography';
import { StatusPill, sessionStatusTone } from './StatusPill';
import { type Product, type Partner, sessionStatusLabel } from '../../types';
import { maskPrices } from '@/lib/utils';
import { useCompanySettings } from '@/features/settings/useCompanySettings';
import { renderEmphasis } from '@/lib/emphasis';
import { renderCardScriptPreview } from '@/features/cards/editor';
// 협상로그 JSON 다운로드(IMK #9). 가격·비율 숫자는 maskPrices 로 가려 내보낸다(화면 표기와 동일 규칙).
// 협상로그 JSON 다운로드(IMK #9). 가격·인하율은 실값 그대로 내보낸다 — 이 탭을 열 수 있는 사람이
// 이미 공개 대상(해당 견적 담당자∪최고관리자, 조회 게이팅)뿐이라 마스킹이 하던 차단 역할이 없다.
// target_price 등 숫자 필드는 아예 제외 — 양식은 대화 흐름(순번/발화자/스텝/멘트/카드사용) 중심.
function exportChatJson(
session: SessionData | undefined,
@ -37,7 +37,7 @@ function exportChatJson(
seq: m.index,
sender: m.sender === ChatSender.BOT ? 'BOT' : 'PARTNER',
step: m.step ?? null,
script: maskPrices(String(m.script ?? '')),
script: String(m.script ?? ''),
card: card
? {
number: card.number ?? null,
@ -153,7 +153,7 @@ export function ChatTab({
type="button"
onClick={() => exportChatJson(currentSession, currentSupplierName, currentProduct?.name, chatMessages, serverCards)}
disabled={chatMessages.length === 0}
title="협상로그 JSON 내보내기 (금액 숫자는 가려서 저장)"
title="협상로그 JSON 내보내기"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-border text-[10px] font-mono text-muted-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
<Download size={11} />
@ -243,7 +243,7 @@ function BotBubble({
)}
{m.script && !usedCard && (
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
{renderEmphasis(maskPrices(m.script))}
{renderEmphasis(m.script)}
</Typography>
)}
<UsedCardBox
@ -259,7 +259,7 @@ function BotBubble({
);
}
// 협력사(우측) 말풍선. 협력사 입력값을 보여주되, 채팅 '내용'에 제시 금액이 노출되지 않도록 maskPrices 로 가린다.
// 협력사(우측) 말풍선. 제시 금액은 실값 그대로 — 열람 가능자가 곧 공개 대상(조회 게이팅)이다.
function PartnerBubble({
message,
currentSupplierName,
@ -288,7 +288,7 @@ function PartnerBubble({
)}
{m.script && (
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
{maskPrices(m.script)}
{m.script}
</Typography>
)}
<UsedCardBox
@ -387,7 +387,7 @@ function UsedCardBox({
</div>
) : usedCard.script ? (
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{renderCardScriptPreview(maskPrices(usedCard.script))}
{renderCardScriptPreview(usedCard.script)}
</Typography>
) : null}

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy, ClipboardList, X } from 'lucide-react';
import { MessageSquare, Copy, Mail, MailCheck, Send, Trophy, ClipboardList, X, Loader2 } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { cn } from '@/lib/utils';
@ -141,7 +141,7 @@ export function SessionsStatusTab({
}
className="flex items-center gap-2 px-3 py-2 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<Mail size={14} />
{sendingAll ? <Loader2 size={14} className="animate-spin" /> : <Mail size={14} />}
<span>{sendingAll ? '발송 중…' : `초청 메일 발송${unsentCount > 0 ? ` (${unsentCount})` : ''}`}</span>
</button>
</div>
@ -296,10 +296,10 @@ export function SessionsStatusTab({
<TooltipTrigger render={
<button
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
disabled={!canNotify || sendingId === sess.session_id}
disabled={!canNotify || sendingAll || sendingId === sess.session_id}
className="p-1 rounded text-primary hover:bg-primary/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send size={12} />
{sendingId === sess.session_id ? <Loader2 size={12} className="animate-spin" /> : <Send size={12} />}
</button>
} />
<TooltipContent>
@ -429,14 +429,20 @@ export function SessionsStatusTab({
)}
<button
onClick={() => handleOne(sess.session_id, sess.supplier_name, !!sess.email_sent_at)}
disabled={!canNotify || sendingId === sess.session_id}
disabled={!canNotify || sendingAll || sendingId === sess.session_id}
className={cn(
'inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed',
sess.email_sent_at ? 'text-success' : 'text-primary',
)}
>
{sess.email_sent_at ? <MailCheck size={12} /> : <Mail size={12} />}
{sess.email_sent_at ? '메일 재발송' : '메일 발송'}
{sendingId === sess.session_id ? (
<Loader2 size={12} className="animate-spin" />
) : sess.email_sent_at ? (
<MailCheck size={12} />
) : (
<Mail size={12} />
)}
{sendingId === sess.session_id ? '발송 중…' : sess.email_sent_at ? '메일 재발송' : '메일 발송'}
</button>
</div>

View File

@ -2,7 +2,7 @@ import { X, Check } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
import { useScrollLock } from '@/lib/useScrollLock';
import { useLabels } from '@/features/settings/useCompanySettings';
import { useLabels, useVatMode } from '@/features/settings/useCompanySettings';
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
@ -39,6 +39,8 @@ export function TargetPriceModal({
}: TargetPriceModalProps) {
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
const label = useLabels(); // 회사 설정 용어
// 부가세 전체 통일 회사는 상품 vat_yn 잔존값과 무관하게 'VAT별도' 고정.
const vatUnified = useVatMode() === 'unified_excluded';
const CANDIDATE_SUB = candidateSub(`${label('target_margin')}`);
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
const candidates = bd?.candidates ?? [];
@ -64,7 +66,7 @@ export function TargetPriceModal({
: {qtNumber} · : {itemName}
</Typography>
<Typography as="p" variant="small" className="text-[11px] font-semibold text-rose-500 mt-1">
: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · : {vatYn ? 'VAT포함' : 'VAT별도'}
: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · : {!vatUnified && vatYn ? 'VAT포함' : 'VAT별도'}
</Typography>
{isLoading || !bd ? (

View File

@ -180,7 +180,9 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, se
},
{
header: '생성일',
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
// 10컬럼 표라 좁은 데스크톱(xl~2xl)에선 부차 정보인 생성일을 접어 가로 스크롤을 줄인다.
headClassName: 'hidden 2xl:table-cell',
cellClassName: 'hidden 2xl:table-cell font-mono text-muted-foreground whitespace-nowrap',
cell: (est) => (
<Typography as="span" variant="small" className="text-xs text-inherit">{est.createdDate ?? '-'}</Typography>
),

View File

@ -1,11 +1,13 @@
import { Fragment, useEffect, useMemo, useRef, useState, type ElementType } from 'react';
import { useSearchParams } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { Palette, Tags, ListPlus, MessageSquareText, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import ImageDropzone from '@/components/ImageDropzone';
import { uploadItemImage } from '@/api/generated/item/item';
import { previewInviteEmail } from '@/api/generated/company-settings/company-settings';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
@ -19,6 +21,8 @@ import {
DEFAULT_GUIDE_NOTICES,
NEGO_BASELINE_OPTIONS,
DEFAULT_NEGO_BASELINE_FIELD,
VAT_MODE_OPTIONS,
DEFAULT_VAT_MODE,
type CompanySettings,
type CustomFieldDef,
type CustomFieldType,
@ -48,6 +52,12 @@ const SETTINGS_TAB_ICON: Record<SettingsTab, ElementType> = {
fields: ListPlus,
};
// branding 의 문자열 서브키(helpdesk 만 배열이라 제외). email_* 는 협상 초청 메일 커스텀.
type BrandingTextKey =
| 'service_name'
| 'logo_url'
| 'email_greeting';
export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly SettingsTab[] }) {
const { settings, isLoading, save } = useCompanySettings();
@ -141,7 +151,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
return res.image_url;
};
const setBranding = (key: 'service_name' | 'logo_url' | 'primary_color' | 'email_header', value: string) =>
const setBranding = (key: BrandingTextKey, value: string) =>
setDraft((d) => ({ ...d, branding: { ...d.branding, [key]: value } }));
const setHelpdesk = (lines: string[]) =>
setDraft((d) => ({ ...d, branding: { ...d.branding, helpdesk: lines } }));
@ -197,7 +207,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
<TabsContent value="branding" className="space-y-4">
<SectionCard
title="서비스 브랜딩"
desc="사이드바·타이틀에 노출되는 서비스명과 로고입니다. 비워두면 기본 브랜드(NegoData)가 사용됩니다."
desc="회사 CI 입니다. 사이드바·타이틀은 물론 공급사 포털(로그인·버튼)과 협상 초청 메일에도 그대로 적용됩니다. 비워두면 기본 브랜드(NegoData)가 사용됩니다."
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="서비스명" hint="예: iMarketKorea 구매협상 콘솔">
@ -229,16 +239,32 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
{draft.branding?.logo_url ? (
<img src={draft.branding.logo_url} alt="로고 미리보기" className="h-4 max-w-24 object-contain" />
) : (
<span
aria-hidden
className="size-4 rounded-[5px]"
style={{ backgroundColor: draft.branding?.primary_color || 'var(--primary)' }}
/>
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
)}
{draft.branding?.service_name || 'NegoData'}
</div>
</div>
</SectionCard>
<SectionCard
title="협상 초청 메일"
desc="공급사 담당자에게 발송되는 협상 초청 메일입니다. 로고·회사명·색은 위 서비스 브랜딩(CI)을 그대로 따르고, 메일에서 바꿀 수 있는 건 인사 문구뿐입니다. 레이아웃은 고정이라 어떤 메일 앱에서도 깨지지 않습니다."
>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_540px] gap-6">
<div className="space-y-4">
<Field label="인사 문구" hint="'OO 담당자님,' 다음 줄에 들어갑니다.">
<textarea
rows={2}
className="w-full p-2 bg-background border border-border rounded text-xs resize-none"
value={draft.branding?.email_greeting ?? ''}
onChange={(e) => setBranding('email_greeting', e.target.value)}
placeholder="아래 견적 건의 협상에 참여해 주세요. (기본값)"
/>
</Field>
</div>
<InviteEmailPreview branding={draft.branding} />
</div>
</SectionCard>
</TabsContent>
{/* ---- 공급사 포털 안내 ---- */}
@ -265,7 +291,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
<LineListEditor
lines={draft.branding?.helpdesk ?? []}
onChange={setHelpdesk}
placeholder="김건우P 02-3708-5832 kw086.kim@imarketkorea.com"
placeholder="홍길동P 02-0000-0000 helpdesk@example.com"
addLabel="연락처 추가"
emptyHint="등록된 연락처가 없습니다. 공급사 포털에 연락처 영역이 표시되지 않습니다."
/>
@ -331,7 +357,7 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
<TabsContent value="fields" className="space-y-4">
<SectionCard
title="협상 기준가"
desc="협상 중 '기존 OO 대비 N% 인하' 를 계산하는 기준 가격입니다. 우리 회사가 공급사에 실제로 지불 중인 단가를 고르십시오."
desc="협상 중 '기존 공급가 대비 N% 인하' 를 계산하는 기준 가격입니다. 우리 회사가 공급사에 실제로 지불 중인 단가를 고르십시오. 공급사 화면 표기는 어느 필드를 고르든 '공급가'로 고정됩니다."
>
<NegoBaselinePicker
value={draft.features?.nego_baseline_field ?? DEFAULT_NEGO_BASELINE_FIELD}
@ -343,6 +369,43 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
/>
</SectionCard>
<SectionCard
title="부가세(VAT) 관리"
desc="가격 정보의 부가세 기준입니다. 전체 통일을 선택하면 상품별 부가세 입력이 폼·엑셀 양식에서 사라지고, 공급사 협상 화면 가격에 'VAT 별도'가 항상 표기됩니다. DB 컬럼과 기존 값은 그대로 남습니다."
>
<div className="space-y-2">
{VAT_MODE_OPTIONS.map((opt) => {
const selected = (draft.features?.vat_mode ?? DEFAULT_VAT_MODE) === opt.value;
return (
<label
key={opt.value}
className={`flex items-start gap-2.5 p-3 rounded-md border cursor-pointer ${
selected ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<input
type="radio"
name="vat_mode"
className="mt-1"
checked={selected}
onChange={() =>
setDraft((d) => ({ ...d, features: { ...d.features, vat_mode: opt.value } }))
}
/>
<span className="min-w-0 space-y-1">
<Typography as="span" variant="small" className="block font-semibold">
{opt.label}
</Typography>
<Typography as="span" variant="caption" className="block text-muted-foreground">
{opt.desc}
</Typography>
</span>
</label>
);
})}
</div>
</SectionCard>
<SectionCard
title="상품 필드 숨김"
desc="체크한 항목은 상품 목록·등록 폼·엑셀 양식에서 감춰집니다. DB 컬럼과 기존 값은 그대로 남습니다."
@ -350,12 +413,23 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
{HIDEABLE_ITEM_FIELDS.map((f) => {
const checked = (draft.hidden_fields ?? []).includes(f.key);
// 협상 기준가로 지정된 필드는 숨김 금지(강제) — 입력 화면이 사라지면 신규 상품의
// 기준값이 NULL 로 쌓여 인하율 멘트가 통째로 빠진다. 이미 숨겨진 상태면 해제만 허용.
const baselineLocked =
f.key === (draft.features?.nego_baseline_field ?? DEFAULT_NEGO_BASELINE_FIELD) &&
!checked;
return (
<label key={f.key} className="flex items-start gap-2 py-1 cursor-pointer">
<label
key={f.key}
className={`flex items-start gap-2 py-1 ${
baselineLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
}`}
>
<input
type="checkbox"
className="mt-0.5"
checked={checked}
disabled={baselineLocked}
onChange={(e) =>
setDraft((d) => {
const cur = new Set(d.hidden_fields ?? []);
@ -372,6 +446,11 @@ export function SettingsView({ tabs = SETTINGS_TABS }: { tabs?: readonly Setting
<Typography as="span" variant="caption" className="block text-muted-foreground">
{f.where}
</Typography>
{baselineLocked && (
<Typography as="span" variant="caption" className="block text-amber-600">
</Typography>
)}
{f.calcNote && (
<Typography as="span" variant="caption" className="block text-amber-600">
{f.calcNote}
@ -484,9 +563,6 @@ function NegoBaselinePicker({
<Typography as="span" variant="caption" className="block text-muted-foreground">
{opt.desc}
</Typography>
<Typography as="span" variant="caption" className="block text-muted-foreground">
{phrase} 3.2% .
</Typography>
{selected && hiddenFields.includes(opt.value) && (
<Typography as="span" variant="caption" className="block text-amber-600">
.
@ -497,6 +573,10 @@ function NegoBaselinePicker({
</label>
);
})}
<Typography variant="caption" className="block text-muted-foreground">
( ) 3.2% .
, &lsquo;&rsquo;.
</Typography>
<Typography variant="caption" className="block text-amber-600">
,
. . ( )
@ -588,6 +668,60 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
);
}
// 초청 메일 미리보기 — 실제 발송과 같은 백엔드 템플릿을 샘플 견적으로 렌더해 iframe 에 띄운다.
// 입력 중 매 타자마다 서버를 부르지 않도록 디바운스하고, 갱신 사이엔 직전 렌더를 유지해 깜빡임을 없앤다.
function InviteEmailPreview({ branding }: { branding?: CompanySettings['branding'] }) {
// 메일 헤더는 회사 CI(로고·서비스명)를 그대로 쓰므로 CI 편집값을 같이 보낸다(미리보기 = 실발송). 색은 솔루션 고정.
const emailBranding = JSON.stringify({
service_name: branding?.service_name ?? '',
logo_url: branding?.logo_url ?? '',
email_greeting: branding?.email_greeting ?? '',
});
const debounced = useDebounced(emailBranding, 400);
const { data, isError } = useQuery({
queryKey: ['invite-email-preview', debounced],
queryFn: () => previewInviteEmail({ branding: JSON.parse(debounced) }),
placeholderData: (prev) => prev,
});
return (
<div className="min-w-0 space-y-2">
<Typography variant="muted" className="text-[10px] block">
릿 .
</Typography>
<div className="border border-border rounded-md overflow-hidden">
<div className="border-b border-border bg-background px-3 py-2">
<Typography variant="caption" className="block text-muted-foreground"></Typography>
<Typography variant="small" className="block font-semibold truncate">
{data?.subject || '미리보기를 불러오는 중...'}
</Typography>
</div>
{isError ? (
<Typography variant="muted" className="block p-6 text-center">
. .
</Typography>
) : (
<iframe
title="협상 초청 메일 미리보기"
srcDoc={data?.html ?? ''}
sandbox=""
className="w-full h-[600px] bg-[#f4f5f7]"
/>
)}
</div>
</div>
);
}
// 값이 delayMs 동안 잠잠해진 뒤에만 반영되는 디바운스 훅(미리보기 서버호출 절약용).
function useDebounced(value: string, delayMs: number): string {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(t);
}, [value, delayMs]);
return debounced;
}
// 커스텀필드 정의 편집 — 표시명을 입력하면 key 를 자동 제안하되 직접 수정도 가능.
function CustomFieldsEditor({
title,

View File

@ -5,6 +5,8 @@ export type CustomFieldType = 'text' | 'number' | 'boolean' | 'select';
export type NegoBaselineField = 'price' | 'purchase_price';
export type VatMode = 'per_item' | 'unified_excluded';
export type CustomFieldDef = {
key: string; // custom JSONB 의 키 (영문 snake_case)
label: string; // 화면 표시명
@ -16,8 +18,7 @@ export type CompanySettings = {
branding?: {
service_name?: string; // 사이드바/타이틀 서비스명 (기본 NegoData)
logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트
primary_color?: string; // 브랜드 색 (hex)
email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA)
email_greeting?: string; // 초청 메일 인사 문구 ('OO 담당자님,' 다음 줄). 메일 헤더는 CI(logo_url·service_name)를 따르고 색은 솔루션 고정
helpdesk?: string[]; // 헬프데스크 연락처 — 한 줄 = 담당자 한 명. 공급사 포털 3곳(로그인·메뉴·안내팝업)이 그대로 출력
};
labels?: Record<string, string>; // 카탈로그 키 → 이 회사 용어 (없으면 기본값)
@ -25,6 +26,9 @@ export type CompanySettings = {
// 협상 기준가로 쓸 상품 가격 컬럼. 인하율 멘트의 분모이자 RL 가격 수용률의 기준가다.
// 미설정이면 공급가(price) — 단 공급가를 숨긴 회사는 매입가로 폴백(협상 엔진).
nego_baseline_field?: NegoBaselineField;
// 부가세 관리 방식. 미설정=상품별(per_item). 전체 통일(unified_excluded)이면 상품 VAT 입력을
// 숨기고(폼·엑셀) 공급사 협상 화면 가격에 'VAT 별도'를 고정 표기한다(협상 백엔드 chat_service).
vat_mode?: VatMode;
[key: string]: unknown; // 그 밖의 회사별 동작 플래그
};
guide_notices?: string[]; // 공급사 포털 협상 유의사항 항목 — 한 줄 = 안내 한 항목. 비면 기본 문구
@ -45,7 +49,7 @@ export type HideableFieldEntry = {
export const HIDEABLE_ITEM_FIELDS: HideableFieldEntry[] = [
{ key: 'made_in', label: '원산지(제조 국가)', where: '상품 등록, 엑셀 양식' },
{ key: 'vat_yn', label: '부가세포함', where: '상품 등록, 엑셀 양식' },
// vat_yn 은 숨김 목록에서 뺐다 — 부가세는 '부가세(VAT) 관리' 모드(features.vat_mode)로 관리한다.
{ key: 'delivery_fee_yn', label: '배송비포함', where: '상품 등록, 엑셀 양식' },
{ key: 'spec', label: '규격', where: '상품 등록, 엑셀 양식' },
{ key: 'manufacturer', label: '제조사', where: '상품 등록, 엑셀 양식' },
@ -69,7 +73,6 @@ export const HIDEABLE_ITEM_FIELDS: HideableFieldEntry[] = [
key: 'price',
label: '상품 단가(공급가)',
where: '상품 목록·등록, 엑셀 양식',
calcNote: '협상 기준가로 고른 경우 숨기지 말 것 — 인하율 멘트의 기준값이 비게 된다',
},
];
// internet_lowest_price 는 숨김 대상에서 뺀다 — 신규 견적의 유일한 목표가 후보라
@ -89,7 +92,8 @@ export const LABEL_CATALOG: LabelCatalogEntry[] = [
{ key: 'item.code', base: '상품코드', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
{ key: 'item.model_name', base: '모델번호', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계', group: '상품' },
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
// 관리자 화면 전용 — 공급사 화면(포털·협상 멘트)의 기준가 표기는 '공급가'로 고정이라 이 용어를 타지 않는다.
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식 (관리자 화면 전용)', group: '상품' },
{ key: 'item.purchase_price', base: '매입가', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.selling_price', base: '판매가', where: '상품 등록, 엑셀 양식', group: '상품' },
{ key: 'item.internet_lowest_price', base: '인터넷 최저가', where: '상품 목록·등록, 엑셀 양식', group: '상품' },
@ -155,6 +159,23 @@ export const NEGO_BASELINE_OPTIONS: {
export const DEFAULT_NEGO_BASELINE_FIELD: NegoBaselineField = 'price';
export const DEFAULT_VAT_MODE: VatMode = 'per_item';
// 부가세 관리 모드 선택지. 전체 통일은 'VAT 별도(제외)' 고정만 둔다 — 포함으로 통일하려는
// 회사가 나오면 값을 추가한다(지금 만들면 안 쓰는 분기만 는다).
export const VAT_MODE_OPTIONS: { value: VatMode; label: string; desc: string }[] = [
{
value: 'per_item',
label: '상품별 관리',
desc: '상품마다 부가세 포함 여부를 입력합니다. 공급사 협상 화면에는 상품에 입력된 값대로 VAT 포함/별도가 표기됩니다.',
},
{
value: 'unified_excluded',
label: '전체 통일 — VAT 별도(제외)',
desc: '모든 가격을 VAT 제외 기준으로 통일합니다. 상품 등록·엑셀 양식에서 부가세 입력이 사라지고, 공급사 협상 화면 가격에는 항상 "VAT 별도"가 표기됩니다.',
},
];
// 공급사 포털 협상 유의사항 기본 문구. 설정이 비어 있을 때 포털이 쓰는 값과 같아야 한다
// (포털 사본: frontend/src/features/chat/components/popup/GuideContent.tsx).
// VAT·배송비 조건은 상품마다 달라 기본 문구에서 뺐다 — 필요한 회사가 항목으로 직접 넣는다.

View File

@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query';
import { useGetSettings, updateSettings, getGetSettingsQueryKey } from '@/api/generated/company-settings/company-settings';
import { LABEL_DEFAULTS, type CompanySettings } from './catalog';
import { LABEL_DEFAULTS, DEFAULT_VAT_MODE, type CompanySettings, type VatMode } from './catalog';
// 회사 커스터마이징 설정 조회 + 저장.
// 조회는 전 유저(브랜딩/라벨 렌더용), 저장은 백엔드가 OWNER 로 게이트한다.
@ -26,18 +26,26 @@ export function useLabels() {
}
// 숨김필드 헬퍼. isHidden('made_in') → 이 회사에서 감출 필드인지.
// 부가세 전체 통일(features.vat_mode) 회사는 상품별 VAT 입력이 무의미하므로 vat_yn 을 숨김으로 친다 —
// 상품 폼·엑셀 양식·데이터 내보내기가 이 헬퍼 하나를 읽어 함께 사라진다.
export function useHiddenFields() {
const { settings } = useCompanySettings();
const hidden = settings.hidden_fields ?? [];
return (key: string): boolean => hidden.includes(key);
const vatUnified = settings.features?.vat_mode === 'unified_excluded';
return (key: string): boolean => hidden.includes(key) || (vatUnified && key === 'vat_yn');
}
// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData).
// 부가세 관리 모드 헬퍼. 미설정이면 상품별(per_item).
export function useVatMode(): VatMode {
const { settings } = useCompanySettings();
return settings.features?.vat_mode ?? DEFAULT_VAT_MODE;
}
// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData). 색은 회사별 커스텀 없이 솔루션 고정.
export function useBranding() {
const { settings } = useCompanySettings();
return {
serviceName: settings.branding?.service_name || 'NegoData',
logoUrl: settings.branding?.logo_url || null,
primaryColor: settings.branding?.primary_color || null,
};
}

View File

@ -23,8 +23,9 @@ export function StatisticsView({ data }: { data: StatData }) {
const markupTrend = fillMonths(data.markupTrend, (month) => ({ month, rate: 0 }));
return (
<div className="space-y-4">
{/* 임팩트 요약 KPI */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
{/* KPI 6 (2xl). xl ~170px
· 3 . */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
<StatTile
label="총 절감액 (목표가 대비)"
value={wonCompact(k.totalSavings)}

View File

@ -1,307 +0,0 @@
import { useState, type ReactNode } from 'react';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/ui/table';
import { TablePagination } from '@/components/ui/table-pagination';
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
DrawerFooter,
DrawerClose,
} from '@/components/ui/drawer';
import { Plus, Upload, TrendingDown, Trash2 } from 'lucide-react';
import { showToast } from '@/lib/notify';
/**
* (dev ).
* import.meta.env.DEV일 .
* / .
*/
export default function DevDesignPage() {
const [switchOn, setSwitchOn] = useState(true);
const [switchOff, setSwitchOff] = useState(false);
const [demoPage, setDemoPage] = useState(2);
return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-4xl px-6 py-12 space-y-12">
<header className="space-y-1">
<Typography variant="mono"> · </Typography>
<Typography variant="h1"></Typography>
<Typography variant="muted">
·· . .
</Typography>
</header>
<Section title="타이포그래피">
<div className="space-y-2">
<Typography variant="h1">H1 · </Typography>
<Typography variant="h2">H2 · </Typography>
<Typography variant="h3">H3 · / </Typography>
<Typography variant="h4">H4 · </Typography>
<Typography variant="body">Body · .</Typography>
<Typography variant="small">Small · .</Typography>
<Typography variant="muted">Muted · .</Typography>
<Typography variant="label">Label · </Typography>
<br />
<Typography variant="mono">Mono · / </Typography>
</div>
</Section>
<Section title="색상 토큰">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Swatch name="background" className="bg-background border border-border" />
<Swatch name="card" className="bg-card border border-border" />
<Swatch name="muted" className="bg-muted" />
<Swatch name="primary" className="bg-primary" />
<Swatch name="secondary" className="bg-secondary" />
<Swatch name="destructive" className="bg-destructive" />
<Swatch name="border" className="bg-border" />
<Swatch name="foreground" className="bg-foreground" />
</div>
</Section>
<Section title="버튼">
<div className="flex flex-wrap items-center gap-2">
{(['default', 'outline', 'secondary', 'ghost', 'destructive', 'link'] as const).map((v) => (
<Button key={v} variant={v}>{v}</Button>
))}
</div>
<div className="flex flex-wrap items-center gap-2 pt-3">
{(['xs', 'sm', 'default', 'lg'] as const).map((s) => (
<Button key={s} size={s}>{s}</Button>
))}
<Button disabled>disabled</Button>
</div>
{/* 실제 사용 케이스: 아이콘+라벨, 카운트 배지, 위험 액션 */}
<div className="flex flex-wrap items-center gap-2 pt-3">
<Button><Plus /> </Button>
<Button variant="outline"><Upload /> </Button>
<Button variant="outline">
<TrendingDown />
<Badge variant="destructive">3</Badge>
</Button>
<Button variant="destructive"><Trash2 /></Button>
</div>
</Section>
<Section title="배지">
<div className="flex flex-wrap items-center gap-2">
{(['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'] as const).map((v) => (
<Badge key={v} variant={v}>{v}</Badge>
))}
</div>
</Section>
<Section title="입력창">
<div className="max-w-sm space-y-3">
<Input placeholder="기본 입력" />
<Input placeholder="비활성" disabled />
</div>
</Section>
<Section title="스위치">
<div className="flex items-center gap-6">
<label className="flex items-center gap-2">
<Switch checked={switchOn} onCheckedChange={setSwitchOn} />
<Typography variant="small">{switchOn ? '켜짐' : '꺼짐'}</Typography>
</label>
<label className="flex items-center gap-2">
<Switch checked={switchOff} onCheckedChange={setSwitchOff} />
<Typography variant="small"></Typography>
</label>
<label className="flex items-center gap-2 opacity-60">
<Switch checked disabled />
<Typography variant="small">disabled</Typography>
</label>
</div>
</Section>
<Section title="카드">
<Card className="max-w-sm">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>shadcn Card . ·· .</CardDescription>
</CardHeader>
<CardContent>
<Typography variant="small"> .</Typography>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button variant="outline" size="sm"></Button>
<Button size="sm"></Button>
</CardFooter>
</Card>
</Section>
<Section title="탭">
<Tabs defaultValue="overview" className="max-w-md">
<TabsList>
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="detail"></TabsTrigger>
<TabsTrigger value="log"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="pt-3">
<Typography variant="small"> .</Typography>
</TabsContent>
<TabsContent value="detail" className="pt-3">
<Typography variant="small"> .</Typography>
</TabsContent>
<TabsContent value="log" className="pt-3">
<Typography variant="small"> .</Typography>
</TabsContent>
</Tabs>
</Section>
<Section title="표">
<div className="rounded-lg border border-border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[
{ name: '배터리 팩', code: 'PROD-BAT-900', price: 1000000 },
{ name: '인버터 블록', code: 'PROD-INV-11', price: 3200000 },
].map((r) => (
<TableRow key={r.code}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell className="font-mono text-muted-foreground">{r.code}</TableCell>
<TableCell className="text-right font-mono">{r.price.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</Section>
<Section title="페이지네이션 푸터">
<div className="rounded-lg border border-border overflow-hidden">
<div className="p-4 text-center">
<Typography variant="small" className="text-muted-foreground"> </Typography>
</div>
<TablePagination
page={demoPage}
totalPages={12}
totalCount={58}
pageSize={5}
onPageChange={setDemoPage}
label="검색 결과"
unit="건"
/>
</div>
</Section>
<Section title="모달">
<Dialog>
<DialogTrigger render={<Button variant="outline"> </Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
base-ui Dialog . fixed inset-0 .
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="outline"></Button>} />
<DialogClose render={<Button></Button>} />
</DialogFooter>
</DialogContent>
</Dialog>
</Section>
<Section title="드로어(사이드 패널)">
<Drawer direction="right">
<DrawerTrigger asChild><Button variant="outline"> </Button></DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle> </DrawerTitle>
<DrawerDescription>
vaul . products의 / .
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<Typography variant="small"> .</Typography>
</div>
<DrawerFooter>
<Button></Button>
<DrawerClose asChild><Button variant="outline"></Button></DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</Section>
<Section title="토스트">
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => showToast('성공적으로 처리되었습니다.', 'success')}>
success
</Button>
<Button variant="outline" onClick={() => showToast('참고할 정보입니다.', 'info')}>
info
</Button>
<Button variant="outline" onClick={() => showToast('오류가 발생했습니다.', 'error')}>
error
</Button>
</div>
<Typography variant="muted" className="pt-2">
: <code className="font-mono">showToast(message, type)</code> (sonner)
</Typography>
</Section>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-4 border-t border-border pt-8">
<Typography variant="h2">{title}</Typography>
{children}
</section>
);
}
function Swatch({ name, className }: { name: string; className: string }) {
return (
<div className="space-y-1.5">
<div className={`h-14 w-full rounded-md ${className}`} />
<Typography variant="mono" className="lowercase tracking-normal">{name}</Typography>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Plus, Upload, TrendingDown, Download, FileDown, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react';
import { Plus, Upload, TrendingDown, Download, FileDown, FileSpreadsheet, ChevronDown, Trash2, ImagePlus } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
@ -18,6 +18,7 @@ import { ProductFormSheet } from '@/features/products/components/ProductFormShee
import { PriceUpdateModal } from '@/features/products/components/PriceUpdateModal';
import { LowestPriceHistorySheet } from '@/features/products/components/LowestPriceHistorySheet';
import { ExcelUploadModal, downloadProductTemplate, downloadProductData } from '@/features/products/components/ExcelUploadModal';
import { ImageBulkUploadModal } from '@/features/products/components/ImageBulkUploadModal';
import { type Product } from '@/features/products/types';
export default function ProductsPage() {
@ -135,24 +136,29 @@ export default function ProductsPage() {
{selectedIds.length > 0 && <Badge variant="destructive">{selectedIds.length}</Badge>}
</Button>
{/* 엑셀 3종 + 이미지 일괄이 한 메뉴에 있어 버튼명은 '일괄 작업'으로 포괄, 항목에서 엑셀/이미지를 명시한다. */}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<FileSpreadsheet />
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
<Upload />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => downloadProductTemplate(label, settings.item_fields ?? [], isHidden)}>
<Download />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}>
<FileDown />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => overlay.open('modal', 'images')}>
<ImagePlus />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -242,6 +248,10 @@ export default function ProductsPage() {
onClose={overlay.close}
/>
)}
{modal === 'images' && (
<ImageBulkUploadModal open loadProducts={fetchAllForExport} onClose={overlay.close} />
)}
</PageContainer>
);
}

View File

@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { keepPreviousData } from '@tanstack/react-query';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { useAuthStore } from '@/stores/auth';
import { useAuthStore, canManage } from '@/stores/auth';
import { confirm } from '@/lib/confirm';
import { showToast } from '@/lib/notify';
import { PageContainer } from '@/components/layout/PageContainer';
@ -30,11 +30,13 @@ export default function QuotationPage() {
const mineFilter = list.filters.mine;
const statusOptions = QUOTATION_STATUS_OPTIONS;
const typeOptions = QUOTATION_TYPE_OPTIONS;
// 일반(USER)은 본인 견적만 조회 — 작성자 필터를 숨기고 mine 을 고정한다(백엔드도 동일하게 강제).
const isManager = useAuthStore((s) => canManage(s.user?.role));
const params: ListQuotationsParams = {
search: list.debouncedSearch || undefined,
status: statusFilter !== 'ALL' ? statusFilter : undefined,
type: typeFilter !== 'ALL' ? typeFilter : undefined,
mine: mineFilter === 'MINE' ? true : undefined,
mine: !isManager || mineFilter === 'MINE' ? true : undefined,
page: list.page,
size: list.pageSize,
};
@ -159,7 +161,8 @@ export default function QuotationPage() {
placeholder="견적명 또는 견적 번호로 검색..."
/>
<div className="grid grid-cols-3 gap-2">
<div className={`grid gap-2 ${isManager ? 'grid-cols-3' : 'grid-cols-2'}`}>
{isManager && (
<Select value={mineFilter} onValueChange={(v) => list.setFilter('mine', v as string)}>
<SelectTrigger id="quotation-mine-filter">
<SelectValue>
@ -171,6 +174,7 @@ export default function QuotationPage() {
<SelectItem value="MINE"> </SelectItem>
</SelectContent>
</Select>
)}
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
<SelectTrigger id="quotation-status-filter">

BIN
owner-settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -0,0 +1,34 @@
-- 2026-08-07 · 최저가 수집 이력에 '몰별 확인 상태' 미러링 (기존 DB 보정)
-- 배경: '그 몰에 더 싼 게 없었다'와 '그 몰이 막혀서 못 봤다'가 화면에서 똑같이 '' 로 보인다.
-- 사용자는 앞쪽으로 읽지만 실제로는 뒤쪽일 수 있다 — 안 본 걸 없다고 말하는 셈이다.
-- by_mall 은 **가격이 있는 몰만** 담으므로 그 구분이 담길 자리가 없었다.
-- LPS 가 lps_db.price_history.sources/partial 로 내려주기 시작했고(2026-08-07),
-- lps_sync_service 가 이 컬럼으로 미러링한다. 상태 정의는 lps/docs/result-states.md.
-- 멱등: ADD COLUMN IF NOT EXISTS — 여러 번 실행해도 안전.
-- 적용: psql -h <host> -p <port> -U <user> -d <db> -f postgres-init/alters/2026-08-07-iilp-source-state.sql
\connect negosium_db
-- 몰별 확인 상태 — {"naver": {"state": "matched", "count": 40},
-- "coupang": {"state": "blocked", "error": "..."}}
-- state: matched / no_match / empty / blocked / env_blocked / unavailable / skipped
ALTER TABLE partner.item_internet_lowest_prices
ADD COLUMN IF NOT EXISTS sources JSONB NULL;
-- 결과가 완전한가. true = 못 본 몰이 있어 이 값이 최종이 아니다.
-- sources 에서 유도할 수 있지만 컬럼으로 둔다 — 화면이 '어떤 상태가 확인된 것인가'라는 판단
-- 규칙까지 알아야 하면 상태 정의가 LPS 와 negodata 두 곳으로 흩어진다. 판단은 LPS 가 끝낸다.
ALTER TABLE partner.item_internet_lowest_prices
ADD COLUMN IF NOT EXISTS partial BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN partner.item_internet_lowest_prices.sources IS
'몰별 확인 상태 {몰: {state, count|error}} — lps_db.price_history.sources 미러링. state=SourceState';
COMMENT ON COLUMN partner.item_internet_lowest_prices.partial IS
'못 본 몰이 있어 결과가 최종이 아님 — 화면은 이 값으로 "일부 확인 못함"을 표시';
-- 검증
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_schema = 'partner' AND table_name = 'item_internet_lowest_prices'
AND column_name IN ('by_mall', 'sources', 'partial')
ORDER BY column_name;

View File

@ -1,49 +1,45 @@
-- LPS 2026-08 스키마 추가분 — **lps_db 에 연결해서 실행**. 재실행 안전(IF NOT EXISTS).
--
-- 3_lps_dbeaver.sql 이후에 늘어난 것들이다. 이미 만들어진 DB(dev·운영)는 모델이 바뀌어도
-- 자동으로 따라오지 않으므로 이 파일로 맞춘다. 신규 설치는 3 → 6 순서로 실행하면 된다.
--
-- ⚠️ 안 돌리면 워커가 기동 중 죽는다 — proxy_port 를 기동 시 반드시 만지기 때문이다
-- (실측 2026-08-06 운영: UndefinedTableError: relation "proxy_port" does not exist → 크래시 루프).
-- LPS 2026-08 추가분 — lps_db 에 연결해서 실행. 재실행 안전(IF NOT EXISTS).
-- 3_lps_dbeaver.sql 이후 늘어난 스키마. 신규 설치는 3 → 6 순서, 기존 DB(dev·운영)는 이 파일만.
-- 안 돌리면 워커가 기동 중 죽는다(proxy_port 를 기동 시 반드시 만진다).
-- ── 1) 프록시 포트(=IP 세션) 임대 장부 ────────────────────────────────────────
-- 한 DECODO 계정을 여러 워커 **프로세스**가 나눠 쓴다. 인메모리로 관리하면 서로의 임대·차단을
-- 몰라 같은 IP 를 동시에 잡거나(요청이 몰려 그 IP 가 빨리 탄다) 방금 태운 IP 를 곧바로 재사용한다.
-- 그래서 잡 큐와 같은 방식(FOR UPDATE SKIP LOCKED)으로 DB 에서 배타 임대한다.
-- 프록시 포트(=IP 세션) 임대 장부 — 한 DECODO 계정을 여러 워커 프로세스가 나눠 쓴다.
-- 인메모리면 서로의 임대·차단을 몰라 같은 IP 를 동시에 잡거나 태운 IP 를 곧바로 재사용한다.
CREATE TABLE IF NOT EXISTS proxy_port (
host VARCHAR(80) NOT NULL, -- 게이트웨이(gate/kr — 같은 번호라도 IP 가 다름)
host VARCHAR(80) NOT NULL, -- 게이트웨이(같은 번호라도 다르면 다른 IP)
port INTEGER NOT NULL,
owner VARCHAR(80) NULL, -- 현재 임대자(소스-PID-워커)
leased_until TIMESTAMPTZ NULL, -- 임대 만료(=sticky 수명). 프로세스가 죽어도 자동 회수
leased_until TIMESTAMPTZ NULL, -- 임대 만료(=sticky 수명). 죽어도 자동 회수
rest_until TIMESTAMPTZ NULL, -- 휴식 만료(예산 선제 회전 — 탄 게 아님)
cooldown_until TIMESTAMPTZ NULL, -- 쿨다운 만료(차단 — 전역 격리)
last_used_at TIMESTAMPTZ NULL, -- 마지막 임대 시각(LRU 회전 기준)
last_used_at TIMESTAMPTZ NULL, -- LRU 회전 기준
last_reason VARCHAR(40) NULL, -- acquire/release/rest/block
use_count INTEGER NOT NULL DEFAULT 0,
burn_count INTEGER NOT NULL DEFAULT 0, -- 누적 차단(불량 IP 슬롯 식별)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (host, port)
);
-- LRU 배정용 — '가장 오래 안 쓴 IP' 를 게이트웨이별로 뽑는다
CREATE INDEX IF NOT EXISTS ix_proxy_port_pick ON proxy_port (host, last_used_at);
-- ── 2) price_history — 최저가 오퍼의 신뢰 신호 ────────────────────────────────
-- 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다. 리뷰·평점이 전혀 없는
-- 오퍼는 재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다.
-- 최저가 오퍼의 신뢰 신호 — 리뷰·평점 없는 오퍼는 재고 없는 미끼가격일 수 있다.
-- NULL(정보 없음)과 0(리뷰 0개)은 뜻이 다르므로 기본값을 두지 않는다.
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_rating NUMERIC(3,2);
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_review_count INTEGER;
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_rating NUMERIC(3,2); -- 평점(5점 만점)
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_review_count INTEGER; -- 리뷰 수
-- ── 3) price_history — 최저가 오퍼의 배송 정보 ────────────────────────────────
-- 순위는 상품가로 매긴다(배송 주체가 로켓/판매자로켓/네이버 판매자로 갈리면 배송비 숫자만으로는
-- 비교가 무의미하다). 그래도 기록은 남겨야 나중에 '배송비를 더하면 순위가 뒤집히는 비율'을
-- 데이터로 판단할 수 있다. fee 는 0=무료 / NULL=미확인(로켓처럼 조건부 무료라 금액 표기가 없는 경우).
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_fee INTEGER;
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_type VARCHAR(20);
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_label VARCHAR(120);
-- 최저가 오퍼의 배송 정보 — 순위는 상품가로 매기지만(배송 주체가 다르면 금액 비교가 무의미)
-- 기록은 남긴다. 나중에 '배송비를 더하면 순위가 뒤집히나'를 데이터로 물을 수 있어야 한다.
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_fee INTEGER; -- 0=무료, NULL=미확인(조건부)
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_type VARCHAR(20); -- free/paid/rocket/rocket_merchant
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_label VARCHAR(120); -- 화면 문구 원문
-- ── 검증 ──────────────────────────────────────────────────────────────────────
-- 워커가 기동 시 요구하는 테이블이 다 있는지 확인한다(전부 OK 여야 정상 기동).
-- 몰별 확인 상태 — by_mall 은 **가격이 있는 몰만** 담아, 빠진 몰이 '거기엔 없더라'인지
-- '거기를 못 봤다'인지 알 수 없었다. 안 본 걸 없다고 말하지 않으려면 이 값이 필요하다.
-- {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
-- state: matched/no_match/empty/blocked/env_blocked/unavailable/skipped (lps/docs/result-states.md)
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS sources JSONB;
ALTER TABLE price_history ADD COLUMN IF NOT EXISTS partial BOOLEAN NOT NULL DEFAULT FALSE; -- 못 본 몰이 있어 최종이 아님
CREATE INDEX IF NOT EXISTS ix_price_history_partial ON price_history (triggered_at) WHERE partial;
-- 적용 확인 — 워커가 기동 시 요구하는 테이블 6종이 전부 OK 여야 정상 기동한다.
SELECT 'proxy_port' AS relation, CASE WHEN to_regclass('public.proxy_port') IS NULL THEN 'MISSING' ELSE 'OK' END AS status
UNION ALL SELECT 'ip_session', CASE WHEN to_regclass('public.ip_session') IS NULL THEN 'MISSING' ELSE 'OK' END
UNION ALL SELECT 'price_history', CASE WHEN to_regclass('public.price_history') IS NULL THEN 'MISSING' ELSE 'OK' END

BIN
quotation-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@ -12,9 +12,7 @@
},
"branding": {
"logo_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/negodata/a35152d2-db61-4760-9f1e-beb9736d957f/items/4ab23e35e4f14d888591ca0f6d343fc8.jpg",
"email_header": "iMarketKorea",
"service_name": "아이좋아네고",
"primary_color": "#f551a0"
"service_name": "아이좋아네고"
},
"item_fields": [
{