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