From 8125a5936678200e86e7de55ae6104ac0067bf93 Mon Sep 17 00:00:00 2001 From: king Date: Fri, 18 Sep 2026 23:25:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(cafe24):=20=EC=98=B5=EC=85=98=EA=B0=92+?= =?UTF-8?q?=ED=92=88=EB=AA=A9=20=ED=86=B5=ED=95=A9=20=EB=AA=A9=EB=A1=9D,?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=88=9C=EC=84=9C,=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=EA=B0=92=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옵션 1개 상품은 한 행에 썸네일·옵션값 이름·품목코드(복사)·자체코드· 추가금액·진열·판매를 두고 「저장」 한 번으로 옵션 PUT → 품목 PUT. - 옵션 없는 상품: 행 추가/삭제 후 저장 → 옵션 생성 → 카페24가 부여한 품목코드를 재시도 조회(products.wait_for_variants)로 받아 자체코드· 추가금액·썸네일까지 이어서 반영. - 순서 드래그는 품목 display_order 로 저장(옵션값 재배열 PUT 은 위치 짝맞춤 때문에 품목코드↔이름이 뒤바뀔 수 있어 사용하지 않음). - 「옵션값 불러오기」: 탭/공백 구분 텍스트(이름·판매가·추가금액·자체코드) 붙여넣기 → 행 자동 채움. 기존 옵션은 이름으로 짝지어 코드/금액만. - 옵션 2개 이상(조합) 상품은 기존 2열 화면 유지. - 모달 JS 를 app/static/cafe24-options.js 로 분리. 유닛테스트 90 통과, 통합 하네스 통과, 헤드리스 크롬 렌더 확인. Co-Authored-By: Claude Fable 5.1 --- app/integrations/cafe24/products.py | 27 + app/modules/cafe24/routes_product_info.py | 12 +- app/modules/cafe24/store.py | 21 +- .../cafe24/templates/cafe24/products.html | 359 +------- app/modules/cafe24/tests/test_cafe24.py | 27 + app/static/cafe24-options.js | 785 ++++++++++++++++++ app/static/cafe24.css | 109 +++ docs/CAFE24_MODULE.md | 22 +- 8 files changed, 1002 insertions(+), 360 deletions(-) create mode 100644 app/static/cafe24-options.js diff --git a/app/integrations/cafe24/products.py b/app/integrations/cafe24/products.py index 845d9ce..c2ba18c 100644 --- a/app/integrations/cafe24/products.py +++ b/app/integrations/cafe24/products.py @@ -402,6 +402,33 @@ def list_variants(client: Cafe24Client, product_no: int) -> list[dict[str, Any]] return variants if isinstance(variants, list) else [] +def wait_for_variants( + client: Cafe24Client, + product_no: int, + expected: int, + *, + attempts: int = 4, + delay: float = 0.8, +) -> list[dict[str, Any]]: + """옵션 생성 직후 카페24가 자동 생성한 품목이 조회될 때까지 짧게 재시도한다. + + 상세설명과 같은 읽기 지연이 품목 조회에도 있다 — POST options 직후 GET variants 가 + 비어 있거나 일부만 올 수 있다. 기대 개수(옵션값 수)만큼 오면 바로 돌려준다. + 끝까지 못 채워도 마지막 결과를 돌려준다(호출부가 안내). + """ + latest: list[dict[str, Any]] = [] + for attempt in range(max(1, attempts)): + if attempt: + time.sleep(delay) + try: + latest = list_variants(client, product_no) + except Exception: # noqa: BLE001 — 조회 실패는 다음 시도로 + latest = [] + if len(latest) >= max(1, expected): + return latest + return latest + + def update_variants( client: Cafe24Client, product_no: int, requests: list[dict[str, Any]] ) -> list[dict[str, Any]]: diff --git a/app/modules/cafe24/routes_product_info.py b/app/modules/cafe24/routes_product_info.py index fe10e9e..3026b28 100644 --- a/app/modules/cafe24/routes_product_info.py +++ b/app/modules/cafe24/routes_product_info.py @@ -327,11 +327,11 @@ def options_create( st.log_audit(actor=actor, action="create_options", product_no=product_no, result="SUCCESS", detail=f"옵션 '{body['options'][0]['option_name']}' 생성: {', '.join(values)}") logger.info("카페24 상품 %s 옵션 생성 (%s)", product_no, actor) - try: - variants = products.list_variants(api.client, product_no) - except Cafe24Error as exc: # 옵션은 만들어졌다 — 품목 목록만 비워서 돌려준다. - logger.warning("카페24 상품 %s 옵션 생성 후 품목 조회 실패: %s", product_no, exc) - variants = [] + # 카페24가 자동 생성한 품목(코드 부여)을 바로 돌려준다 — 읽기 지연이 있어 짧게 재시도. + # 화면은 이 코드로 자체코드·추가금액을 이어서 PUT 한다. + variants = products.wait_for_variants(api.client, product_no, len(values)) + if len(variants) < len(values): + logger.warning("카페24 상품 %s 옵션 생성 후 품목 %s/%s 건만 조회됨", product_no, len(variants), len(values)) return {"ok": True, "option": _options_view(created), "variants": [_variant_view(v) for v in variants]} @@ -484,7 +484,7 @@ def variants_update( for r in results: code = str(r.get("variant_code") or "") if code in by_code: - for key in ("custom_variant_code", "additional_amount", "display", "selling"): + for key in ("custom_variant_code", "additional_amount", "display", "selling", "display_order"): if key in r and r[key] is not None: by_code[code][key] = r[key] st.save_write_snapshot(product_no, "variants", by_code) diff --git a/app/modules/cafe24/store.py b/app/modules/cafe24/store.py index 59f711c..9874cdf 100644 --- a/app/modules/cafe24/store.py +++ b/app/modules/cafe24/store.py @@ -649,10 +649,16 @@ ADDITIONAL_AMOUNT_MAX = 2_147_483_647 def parse_option_values(raw: object) -> list[str]: - """'빨강, 파랑\\n노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지).""" - text = str(raw or "") + """'빨강, 파랑\\n노랑' 또는 ['빨강','파랑'] → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지). + + 목록으로 오면 항목 안의 쉼표는 이름의 일부로 본다(화면이 행 단위로 보낼 때). + """ + if isinstance(raw, (list, tuple)): + parts = [str(p) for p in raw] + else: + parts = re.split(r"[,\n]", str(raw or "")) seen: list[str] = [] - for part in re.split(r"[,\n]", text): + for part in parts: value = part.strip() if value and value not in seen: seen.append(value) @@ -785,6 +791,15 @@ def build_variant_updates(rows: list[dict]) -> list[dict]: for flag in ("display", "selling"): if row.get(flag) is not None: item[flag] = "T" if parse_tristate(row[flag]) else "F" + # 진열 순서(1~300) — 카페24 문서: 조합형 옵션 품목에만. 화면의 드래그 정렬이 보낸다. + if row.get("display_order") is not None: + try: + order = int(row["display_order"]) + except (TypeError, ValueError): + raise ValueError("진열 순서는 숫자여야 합니다.") from None + if not 1 <= order <= 300: + raise ValueError("진열 순서는 1~300 사이여야 합니다.") + item["display_order"] = order if len(item) > 1: out.append(item) return out diff --git a/app/modules/cafe24/templates/cafe24/products.html b/app/modules/cafe24/templates/cafe24/products.html index 00e5216..a56b251 100644 --- a/app/modules/cafe24/templates/cafe24/products.html +++ b/app/modules/cafe24/templates/cafe24/products.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} @@ -125,6 +125,7 @@ {% endblock %} {% block scripts %} +