From 119a128ee0cd41593d8b53d46e04257a31be0bcb Mon Sep 17 00:00:00 2001 From: king Date: Fri, 14 Aug 2026 12:34:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(cafe24):=20=EC=83=81=ED=92=88=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=A2=8C=EC=9A=B0=202=EB=B6=84=ED=95=A0?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EB=AA=A9=EB=A1=9D(=EC=A2=81=EA=B2=8C)?= =?UTF-8?q?=20|=20=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91(=EB=84=93=EA=B2=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왼쪽에서 상품을 클릭하면 오른쪽에 편집기가 바로 열린다. 목록을 다시 받지 않고 오른쪽 조각만 교체한다(GET /products/{no}/pane → JS 삽입). 목록까지 다시 그리면 클릭마다 카페24 호출이 2회 더 늘어나기 때문이다. JS 가 실패하거나 없으면 각 행의 링크(/cafe24/?selected=)로 그대로 동작한다. 목록은 페이지를 없애고 전체를 한 번에 받는다(list_all_products, 1회 100개·상한 1000개). 필터·정렬을 한 페이지에만 적용하면 다음 페이지에 있는 상품이 빠져 "진열중만 보기" 가 거짓이 된다. 현재 87개라 1회 호출로 끝난다. 컬럼은 요청대로 번호·상품명·진열·판매·수정 5개다. 좁은 칸에 맞춰 진열/판매는 배지 대신 점, 수정일은 월-일만 표시하고 전체 값은 title 로 둔다. 긴 상품명은 2줄로 제한해 행 높이를 고르게 유지한다(전체 이름은 title·편집기 제목에서 확인). 진열중/판매중 체크박스는 중복 선택이 되며 둘 다 켜면 AND 다. 문서에 없는 API 필터 파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다. 제목행 클릭은 오름↔내림 토글이며 한글 정렬은 localeCompare(ko) 를 쓴다. 편집 영역을 넓게 쓰려고 이 화면에서만 .erp-page 의 max-width 를 풀었다. 이때 box-sizing:border-box 를 함께 줘야 한다 — width:100% + padding:24px 이라 max-width 만 풀면 문서 전체에 가로 스크롤이 생긴다(측정으로 확인 후 수정). 편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 저장 안 됨 경고를 띄운다. 옛 단독 화면(product.html)은 제거하고 /products/{no} 는 2분할 화면으로 리다이렉트한다. 편집기 조각을 두 곳에서 함께 쓰도록 _editor.html 로 분리했다. 검증: 유닛테스트 33개 통과(신규 3개 — 전체 조회의 페이지 순회·상한 처리·1회 종료). 상한 처리는 테스트가 잡아서 고쳤다(요청한 만큼 받았는지로 판정). 가짜 데이터로 렌더해 브라우저에서 실측: 왼쪽 360px·오른쪽 940px, 각 칸 독립 스크롤, 분할 영역이 화면 높이에 맞고, 가로 스크롤 없음, 정렬 오름/내림 동작 확인. Co-Authored-By: Claude Opus 5 --- app/integrations/cafe24/products.py | 33 +++ app/modules/cafe24/routes_products.py | 238 ++++++++------- .../cafe24/templates/cafe24/_editor.html | 102 +++++++ .../cafe24/templates/cafe24/product.html | 180 ----------- .../cafe24/templates/cafe24/products.html | 279 +++++++++++++----- app/modules/cafe24/tests/test_cafe24.py | 39 +++ app/static/cafe24.css | 269 +++++++++++++++++ docs/CAFE24_MODULE.md | 39 ++- 8 files changed, 828 insertions(+), 351 deletions(-) create mode 100644 app/modules/cafe24/templates/cafe24/_editor.html delete mode 100644 app/modules/cafe24/templates/cafe24/product.html diff --git a/app/integrations/cafe24/products.py b/app/integrations/cafe24/products.py index 34c1958..feab56e 100644 --- a/app/integrations/cafe24/products.py +++ b/app/integrations/cafe24/products.py @@ -71,6 +71,39 @@ def list_products( return products if isinstance(products, list) else [] +def list_all_products( + client: Cafe24Client, + *, + product_name: str = "", + max_items: int = 1000, +) -> tuple[list[dict[str, Any]], bool]: + """전체 상품을 페이지를 넘겨가며 모두 가져온다. + + 2분할 화면의 왼쪽 목록은 페이지 없이 한 번에 보여주고 필터·정렬을 브라우저에서 + 처리한다. 그래야 "진열중만" 같은 필터가 전체 기준으로 정확해진다 + (한 페이지만 받아 걸러내면 다음 페이지의 해당 상품이 빠진다). + + 반환: (상품 목록, 상한에 걸려 잘렸는지) + 상품이 max_items 를 넘으면 거기서 멈춘다 — 무한 호출로 API 제한에 걸리는 + 것을 막기 위한 안전장치다(현재 쇼핑몰 87개, 1회 100개 조회). + """ + collected: list[dict[str, Any]] = [] + while len(collected) < max_items: + want = min(PAGE_LIMIT, max_items - len(collected)) + batch = list_products( + client, + limit=want, + offset=len(collected), + product_name=product_name, + ) + collected.extend(batch) + if len(batch) < want: + return collected, False # 요청한 만큼 못 받았다 = 마지막 페이지 + if len(collected) >= max_items: + return collected, True # 상한에서 멈췄다 — 뒤에 더 있을 수 있다 + return collected, False + + def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]: """상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다.""" no = int(product_no) diff --git a/app/modules/cafe24/routes_products.py b/app/modules/cafe24/routes_products.py index e260b7e..454ebf9 100644 --- a/app/modules/cafe24/routes_products.py +++ b/app/modules/cafe24/routes_products.py @@ -1,12 +1,16 @@ -"""카페24 상품 화면 — 목록/검색 · 상세설명(HTML) 조회 · 편집 후 즉시 적용. +"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집). -카페24를 언제나 source of truth 로 본다. 목록도 상세설명도 화면을 열 때마다 -API 로 현재값을 읽고, 목록 결과는 `cafe24_products` 캐시에 UPSERT 한다 -(예약·로그 화면에서 API 없이 상품명을 보여주기 위한 용도). +화면 구성 + 왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬. + 오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게. -상세설명은 상품 리소스의 필드다(`/description` 서브리소스는 존재하지 않는다 — -app/integrations/cafe24/products.py 주석 참고). 목록 응답에는 상세설명이 없어 -상품 1건씩 조회해야 하므로, 목록 화면에서는 미리보기를 뿌리지 않는다. +목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을 +브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음 +페이지에 있는 해당 상품이 빠진다. + +상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을 +돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로 +끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다. 쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다. 카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT → @@ -25,6 +29,7 @@ from __future__ import annotations import logging from typing import Any +from urllib.parse import urlencode from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse @@ -38,15 +43,10 @@ logger = logging.getLogger("cafe24.products") products_router = APIRouter() -# 한 화면에 보여줄 상품 수. 카페24 1회 조회 한도(100)를 넘지 않는다. -PAGE_SIZE = 50 - -def _page_param(raw: str | None) -> int: - try: - return max(1, int(raw or 1)) - except ValueError: - return 1 +def _checked(request: Request, name: str) -> bool: + """체크박스 → bool. 값이 무엇이든 파라미터가 있으면 체크된 것으로 본다.""" + return request.query_params.get(name) is not None def _short_dt(value: Any) -> str: @@ -58,75 +58,33 @@ def _short_dt(value: Any) -> str: def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]: - """목록 표에 쓸 필드만 골라낸다(응답 필드가 90개라 그대로 넘기지 않는다).""" + """왼쪽 목록에 쓸 필드만 — 상품번호·상품명·진열·판매·최근수정.""" normalized = products.normalize_product(raw) return { - **normalized, + "product_no": normalized["product_no"], + "product_name": normalized["product_name"], + "display": normalized["display"], + "selling": normalized["selling"], "updated_date": _short_dt(raw.get("updated_date")), - "price": str(raw.get("price") or ""), } -@products_router.get("/", response_class=HTMLResponse) -def product_list(request: Request) -> HTMLResponse: - """상품 목록/검색. 검색어는 상품명 부분일치(카페24 API 가 처리).""" - from app.main import render_template # noqa: WPS433 - - checked = guard(request) - if not isinstance(checked, tuple): - return checked - st, user = checked - +def _list_query(request: Request, *, selected: int | None = None) -> str: + """현재 검색·필터를 유지한 목록 URL 쿼리스트링.""" + params: list[tuple[str, str]] = [] keyword = (request.query_params.get("q") or "").strip() - page = _page_param(request.query_params.get("page")) - - api = build_cafe24_api(st) - rows: list[dict[str, Any]] = [] - total = 0 - error = "" - try: - total = products.count_products(api.client, product_name=keyword) - raw_rows = products.list_products( - api.client, - limit=PAGE_SIZE, - offset=(page - 1) * PAGE_SIZE, - product_name=keyword, - ) - rows = [_row_for_list(r) for r in raw_rows] - st.upsert_products([products.normalize_product(r) for r in raw_rows]) - except Cafe24Error as exc: - # 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다. - error = str(exc) - logger.warning("카페24 상품 목록 조회 실패: %s", exc) - - last_page = max(1, -(-total // PAGE_SIZE)) if total else 1 - - ctx = base_ctx(request, user, active_tab="products") - ctx.update( - { - "page_title": "카페24 상품관리", - "page_subtitle": "상품 상세페이지 조회·편집·예약", - "rows": rows, - "keyword": keyword, - "page": page, - "last_page": last_page, - "total": total, - "error": error, - } - ) - return render_template(request, "cafe24/products.html", ctx) + if keyword: + params.append(("q", keyword)) + for flag in ("display", "selling"): + if _checked(request, flag): + params.append((flag, "1")) + if selected: + params.append(("selected", str(selected))) + return urlencode(params) -@products_router.get("/products/{product_no}", response_class=HTMLResponse) -def product_detail(request: Request, product_no: int) -> HTMLResponse: - """상품 1건 — 기본정보 + 카페24에 지금 올라가 있는 상세설명 HTML.""" - from app.main import render_template # noqa: WPS433 - - checked = guard(request) - if not isinstance(checked, tuple): - return checked - st, user = checked - +def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]: + """오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다.""" api = build_cafe24_api(st) product: dict[str, Any] = {} desc = None @@ -140,32 +98,109 @@ def product_detail(request: Request, product_no: int) -> HTMLResponse: logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc) info = products.normalize_product(product) if product else {} + return { + "product_no": product_no, + "info": { + **info, + "price": str(product.get("price") or ""), + "updated_date": _short_dt(product.get("updated_date")), + "summary_description": product.get("summary_description") or "", + }, + "desc": desc, + # 편집기에는 이미지 경로의 %EC%9A%A9… 을 한글로 풀어서 보여준다. + # 저장할 때 다시 인코딩하므로 카페24에 저장되는 값은 그대로다. + "html_pc": store.decode_html_urls(desc.description) if desc else "", + "html_mobile": store.decode_html_urls(desc.mobile_description) if desc else "", + # 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로). + "fingerprint": store.fingerprint(desc.description) if desc else "", + "revisions": st.list_revisions(product_no, limit=20), + "editor_error": error, + } + + +@products_router.get("/", response_class=HTMLResponse) +def product_list(request: Request) -> HTMLResponse: + """2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다.""" + from app.main import render_template # noqa: WPS433 + + checked = guard(request) + if not isinstance(checked, tuple): + return checked + st, user = checked + + keyword = (request.query_params.get("q") or "").strip() + only_display = _checked(request, "display") + only_selling = _checked(request, "selling") + + api = build_cafe24_api(st) + rows: list[dict[str, Any]] = [] + total = 0 + truncated = False + error = "" + try: + raw_rows, truncated = products.list_all_products(api.client, product_name=keyword) + st.upsert_products([products.normalize_product(r) for r in raw_rows]) + total = len(raw_rows) + rows = [_row_for_list(r) for r in raw_rows] + # 필터는 전체를 받아온 뒤 적용한다(문서에 없는 API 파라미터에 기대지 않는다). + if only_display: + rows = [r for r in rows if r["display"]] + if only_selling: + rows = [r for r in rows if r["selling"]] + except Cafe24Error as exc: + # 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다. + error = str(exc) + logger.warning("카페24 상품 목록 조회 실패: %s", exc) + + try: + selected = int(request.query_params.get("selected") or 0) + except ValueError: + selected = 0 + ctx = base_ctx(request, user, active_tab="products") ctx.update( { - "page_title": f"카페24 상품 {product_no}", - "page_subtitle": product.get("product_name") or "상품 상세페이지", - "product_no": product_no, - "info": { - **info, - "price": str(product.get("price") or ""), - "updated_date": _short_dt(product.get("updated_date")), - "summary_description": product.get("summary_description") or "", - }, - "desc": desc, - # 편집기에는 이미지 경로의 %EC%9A%A9… 을 한글로 풀어서 보여준다. - # 저장할 때 다시 인코딩하므로 카페24에 저장되는 값은 그대로다. - "html_pc": store.decode_html_urls(desc.description) if desc else "", - "html_mobile": store.decode_html_urls(desc.mobile_description) if desc else "", - # 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로). - "fingerprint": store.fingerprint(desc.description) if desc else "", - "revisions": st.list_revisions(product_no, limit=20), + "page_title": "카페24 상품관리", + "page_subtitle": "상품 상세페이지 조회·편집·예약", + "rows": rows, + "total": total, + "shown": len(rows), + "truncated": truncated, + "keyword": keyword, + "only_display": only_display, + "only_selling": only_selling, + "selected": selected, + "list_query": _list_query(request), "error": error, "flash": request.query_params.get("msg", ""), "flash_error": request.query_params.get("err", ""), } ) - return render_template(request, "cafe24/product.html", ctx) + if selected: + ctx.update(_editor_ctx(st, selected)) + return render_template(request, "cafe24/products.html", ctx) + + +@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse) +def product_pane(request: Request, product_no: int) -> HTMLResponse: + """오른쪽 편집기 조각만 — 목록을 다시 그리지 않기 위해 JS 가 가져간다.""" + from app.main import render_template # noqa: WPS433 + + checked = guard(request) + if not isinstance(checked, tuple): + return checked + st, user = checked + + ctx = base_ctx(request, user, active_tab="products") + ctx.update(_editor_ctx(st, product_no)) + ctx["list_query"] = _list_query(request) + return render_template(request, "cafe24/_editor.html", ctx) + + +@products_router.get("/products/{product_no}") +def product_redirect(request: Request, product_no: int): + """옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다.""" + return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303) @products_router.post("/products/{product_no}/apply") @@ -175,6 +210,7 @@ def product_apply( html: str = Form(""), base_fingerprint: str = Form(""), memo: str = Form(""), + list_query: str = Form(""), ): """편집한 HTML 을 카페24에 즉시 적용한다. @@ -193,12 +229,14 @@ def product_apply( st, user = checked actor = str(user.get("email") or "") - back = f"/cafe24/products/{product_no}" + # 적용 후에는 검색·필터를 유지한 채 같은 상품이 선택된 화면으로 돌아온다. + base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}" + back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}" submitted = store.encode_html_urls(html or "") if not submitted.strip(): return RedirectResponse( - url=f"{back}?err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.", + url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.", status_code=303, ) @@ -210,7 +248,7 @@ def product_apply( actor=actor, action="apply_description", product_no=product_no, result="FAIL", detail=f"현재값 조회 실패: {exc}", ) - return RedirectResponse(url=f"{back}?err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303) + return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303) backup_id = st.add_revision( product_no=product_no, @@ -226,12 +264,12 @@ def product_apply( revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨", ) return RedirectResponse( - url=f"{back}?err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.", + url=f"{back}&err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.", status_code=303, ) if submitted == current.description: - return RedirectResponse(url=f"{back}?msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) + return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) # 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다). mobile_html = None if current.separated_mobile else submitted @@ -246,7 +284,7 @@ def product_apply( ) logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc) return RedirectResponse( - url=f"{back}?err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)", + url=f"{back}&err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)", status_code=303, ) @@ -265,6 +303,6 @@ def product_apply( ) logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor) return RedirectResponse( - url=f"{back}?msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", + url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", status_code=303, ) diff --git a/app/modules/cafe24/templates/cafe24/_editor.html b/app/modules/cafe24/templates/cafe24/_editor.html new file mode 100644 index 0000000..f17d46e --- /dev/null +++ b/app/modules/cafe24/templates/cafe24/_editor.html @@ -0,0 +1,102 @@ +{# 오른쪽 편집기 조각. + 전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로 + 같은 조각만 다시 받아 끼워 넣는다. 그래서 여기에는 -{% endblock %} diff --git a/app/modules/cafe24/templates/cafe24/products.html b/app/modules/cafe24/templates/cafe24/products.html index 109d515..75855c4 100644 --- a/app/modules/cafe24/templates/cafe24/products.html +++ b/app/modules/cafe24/templates/cafe24/products.html @@ -1,11 +1,15 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} {% include "cafe24/_nav.html" %} +{% set qs = (list_query ~ '&') if list_query else '' %} + +{% if flash %}
{{ flash }}
{% endif %} +{% if flash_error %}
{{ flash_error }}
{% endif %} {% if error %}
@@ -14,74 +18,217 @@
{% endif %} -
-
-

상품 목록

- - {% if keyword %}“{{ keyword }}” 검색 결과 {{ total }}건{% else %}전체 {{ total }}건{% endif %} - · {{ page }} / {{ last_page }} 페이지 - -
+
-
- - - {% if keyword %}전체보기{% endif %} -
+ {# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #} + - {% if last_page > 1 %} -
- {% if page > 1 %} - ← 이전 + {# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #} +
+ {% if selected %} + {% include "cafe24/_editor.html" %} + {% else %} +
+

왼쪽에서 상품을 선택하세요.

+

+ 선택한 상품의 상세페이지 HTML 을 여기서 바로 편집하고 카페24에 적용할 수 있습니다.
+ 적용 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다. +

+
{% endif %} - {{ page }} / {{ last_page }} - {% if page < last_page %} - 다음 → - {% endif %} -
- {% endif %} - - {% elif not error %} -

- {% if keyword %}“{{ keyword }}” 로 찾은 상품이 없습니다.{% else %}표시할 상품이 없습니다.{% endif %} -

- {% endif %} +
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/modules/cafe24/tests/test_cafe24.py b/app/modules/cafe24/tests/test_cafe24.py index f8f1bec..bc3cb63 100644 --- a/app/modules/cafe24/tests/test_cafe24.py +++ b/app/modules/cafe24/tests/test_cafe24.py @@ -333,6 +333,45 @@ def test_normalize_product_flags(): assert products.normalize_product({"product_no": "9"})["display"] is True +class _PagingClient: + """페이지를 넘겨가며 응답하는 가짜 클라이언트.""" + + def __init__(self, count: int): + self.count = count + self.calls: list[dict] = [] + + def get(self, path, *, params=None, json=None, product_no=None): + self.calls.append(dict(params or {})) + offset = int((params or {}).get("offset", 0)) + limit = int((params or {}).get("limit", 100)) + page = [{"product_no": n} for n in range(offset, min(offset + limit, self.count))] + return {"products": page} + + +def test_list_all_products_walks_pages(): + client = _PagingClient(230) + rows, truncated = products.list_all_products(client) + assert len(rows) == 230 and truncated is False + # 100 + 100 + 30 → 3회 호출로 끝나야 한다. + assert len(client.calls) == 3 + assert [c["offset"] for c in client.calls] == [0, 100, 200] + + +def test_list_all_products_stops_at_cap(): + """상한을 넘으면 잘렸다고 알린다 — 무한 호출로 API 제한에 걸리지 않게.""" + client = _PagingClient(10_000) + rows, truncated = products.list_all_products(client, max_items=150) + assert len(rows) == 150 and truncated is True + + +def test_list_all_products_single_page(): + """현재 쇼핑몰(87개)은 1회 호출로 끝난다.""" + client = _PagingClient(87) + rows, truncated = products.list_all_products(client) + assert len(rows) == 87 and truncated is False + assert len(client.calls) == 1 + + def test_list_products_clamps_paging(): client = _FakeClient({"products": []}) products.list_products(client, limit=999, offset=-5, product_name="락") diff --git a/app/static/cafe24.css b/app/static/cafe24.css index 92ed01f..784c519 100644 --- a/app/static/cafe24.css +++ b/app/static/cafe24.css @@ -11,10 +11,279 @@ flex-shrink: 0; } +/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다. + box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서 + max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */ +.erp-page { + max-width: none; + box-sizing: border-box; +} + .cf24-card { margin-bottom: var(--sp-16, 16px); } +/* ════════════════════════════════════════════════════════════ + 좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게) + 각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다. + ════════════════════════════════════════════════════════════ */ +.cf24-split { + display: grid; + grid-template-columns: 360px minmax(0, 1fr); + gap: var(--sp-12, 12px); + align-items: stretch; +} + +/* .erp-page 는 flex column 이다. 위 flex-shrink:0 규칙의 예외로, 분할 영역만은 + 남은 높이를 모두 차지하고 안에서 스크롤되게 한다(휴가/쿠팡 모듈과 같은 방식). */ +.erp-page > .cf24-split { + flex: 1 1 auto; + min-height: 0; +} + +.cf24-pane { + min-height: 0; + overflow: auto; + margin-bottom: 0; + padding: var(--sp-12, 12px); +} + +.cf24-pane-list { + display: flex; + flex-direction: column; + gap: var(--sp-8, 8px); + overflow: hidden; /* 표만 스크롤 — 검색/필터는 고정 */ +} + +.cf24-pane-editor { + display: flex; + flex-direction: column; + gap: var(--sp-8, 8px); + padding: var(--sp-16, 16px); +} + +@media (max-width: 1100px) { + .cf24-split { + grid-template-columns: minmax(0, 1fr); + } + .cf24-pane { + overflow: visible; + } + .cf24-pane-list { + max-height: 45vh; + } +} + +/* ── 왼쪽: 검색·필터 ── */ +.cf24-filters { + display: flex; + flex-direction: column; + gap: var(--sp-8, 8px); + flex: 0 0 auto; +} + +.cf24-checks { + display: flex; + gap: var(--sp-12, 12px); + font-size: var(--text-caption, 12px); +} + +.cf24-checks label { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.cf24-list-count { + font-size: var(--text-caption, 12px); + color: var(--color-midtone-gray, #737373); +} + +/* ── 왼쪽: 목록 표 (좁게) ── */ +.cf24-list-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.cf24-list-table { + font-size: 12px; + table-layout: fixed; + width: 100%; +} + +.cf24-list-table thead th { + position: sticky; + top: 0; + z-index: 1; + padding: 6px 4px; + white-space: nowrap; +} + +.cf24-list-table tbody td { + padding: 6px 4px; + vertical-align: top; +} + +/* 고정폭 합계를 최소로 잡아 상품명에 남은 폭을 준다. + 진열/판매 칸은 제목(2글자) + 정렬 화살표가 들어갈 만큼만. */ +.cf24-col-no { width: 36px; text-align: right; color: var(--color-midtone-gray, #737373); } +.cf24-col-flag { width: 40px; text-align: center; } +.cf24-col-date { width: 46px; white-space: nowrap; color: var(--color-midtone-gray, #737373); } +.cf24-col-name { word-break: break-word; } + +/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다. + 전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */ +.cf24-col-name a { + color: inherit; + text-decoration: none; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; +} + +.cf24-row { + cursor: pointer; +} + +.cf24-row:hover { + background: var(--color-ghost-gray, #f2f2f2); +} + +.cf24-row.is-active { + background: var(--color-rich-black, #0a0a0a); +} + +.cf24-row.is-active td, +.cf24-row.is-active .cf24-col-name a { + color: #fff; +} + +/* 진열/판매 표시는 좁은 칸이라 배지 대신 점으로 */ +.cf24-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-subtle-ash, #e5e5e5); +} + +.cf24-dot-on { + background: var(--color-success-green, #10c22b); +} + +/* 제목행 클릭 정렬 */ +.cf24-sortable { + cursor: pointer; + user-select: none; +} + +.cf24-sortable::after { + content: "↕"; + opacity: 0.35; + margin-left: 2px; +} + +.cf24-sortable.is-asc::after { content: "▲"; opacity: 1; } +.cf24-sortable.is-desc::after { content: "▼"; opacity: 1; } + +/* ── 오른쪽: 편집기 ── */ +.cf24-editor-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--sp-12, 12px); + flex: 0 0 auto; +} + +.cf24-editor-title { + margin: 0; + font-size: var(--text-heading, 18px); + letter-spacing: -0.45px; +} + +.cf24-editor-sub { + margin: 4px 0 0; + font-size: var(--text-caption, 12px); + color: var(--color-midtone-gray, #737373); +} + +.cf24-editor-badges { + display: flex; + gap: 4px; + flex: 0 0 auto; +} + +.cf24-editor-form { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + gap: var(--sp-8, 8px); +} + +.cf24-editor-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--sp-8, 8px); + flex-wrap: wrap; + flex: 0 0 auto; +} + +.cf24-editor-bar-right { + display: flex; + gap: var(--sp-8, 8px); + align-items: center; +} + +.cf24-memo { + width: 260px; + padding: 6px var(--sp-10, 10px); + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-lg, 10px); + font-size: 12px; +} + +.cf24-html-main { + flex: 1 1 auto; + min-height: 340px; +} + +.cf24-details { + flex: 0 0 auto; + border-top: 1px solid var(--color-subtle-ash, #e5e5e5); + padding-top: var(--sp-8, 8px); +} + +.cf24-details > summary { + cursor: pointer; + font-size: var(--text-caption, 12px); + color: var(--color-midtone-gray, #737373); +} + +.cf24-compact { + font-size: 12px; +} + +.cf24-compact tbody td, +.cf24-compact thead th { + padding: 6px 8px; +} + +.cf24-empty-pane { + margin: auto; + text-align: center; + padding: var(--sp-24, 24px); +} + +.cf24-empty-pane h3 { + margin: 0 0 var(--sp-8, 8px); +} + .cf24-card-head { display: flex; align-items: center; diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md index b769848..4d2857a 100644 --- a/docs/CAFE24_MODULE.md +++ b/docs/CAFE24_MODULE.md @@ -25,14 +25,14 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 app/modules/cafe24/ ← 상품관리 모듈 ├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 -├─ routes_products.py 상품 목록/검색 · 상세설명 조회 (읽기 전용) +├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기) ├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그 ├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리) ├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) ├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 ├─ tests/ DB/네트워크 없는 유닛테스트 -└─ templates/cafe24/ _nav.html · products.html · product.html · - schedules.html · system.html +└─ templates/cafe24/ _nav.html · products.html(2분할) · + _editor.html(오른쪽 조각) · schedules.html · system.html ``` **규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시 @@ -48,8 +48,9 @@ app/modules/cafe24/ ← 상품관리 모듈 | 경로 | 화면 | 권한 | | --- | --- | --- | -| `GET /cafe24/` | 상품 목록·검색 (`q`, `page`) | `cafe24` | -| `GET /cafe24/products/{product_no}` | 상품 1건 + 상세설명 HTML 편집기 + 버전 이력 | `cafe24` | +| `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` | +| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` | +| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` | | `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` | | `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` | | `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` | @@ -87,6 +88,34 @@ cafe24_oauth_tokens 저장 --- +### 2-1. 상품관리 화면 구성 (2분할) + +``` +┌─ 360px ─────────┬────────────── 남은 폭 전부 ──────────────┐ +│ 검색 / 진열·판매 │ 선택한 상품 이름·상태 │ +│ 필터(중복 선택) │ PC 상세설명 HTML 편집(칸이 남은 높이 차지) │ +│ ── 목록 ── │ [메모] [복사] [카페24에 적용] │ +│ 번호 상품명 진열 │ ▸ 모바일 HTML(읽기 전용, 분리 상품만) │ +│ 판매 수정 │ ▸ 버전 이력 │ +└─────────────────┴───────────────────────────────────────────┘ +``` + +- **왼쪽은 전체 목록**(페이지 없음). `list_all_products` 로 페이지를 넘겨가며 전부 + 받는다(1회 100개, 상한 1000개). 필터를 한 페이지에만 적용하면 다음 페이지의 + 해당 상품이 빠지기 때문이다. +- **필터**는 `진열중`/`판매중` 체크박스이며 **중복 선택 시 AND** 다. 문서에 없는 API + 파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다. +- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔 + 덕분에 목록 전체가 대상이 된다. +- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지 + 않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다. +- 편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 **저장 안 됨 경고**가 뜬다. +- `.erp-page` 의 `max-width` 를 이 화면에서만 풀어 편집 영역을 넓게 쓴다. 이때 + `box-sizing: border-box` 를 함께 줘야 한다(안 주면 padding 이 폭에 더해져 문서에 + 가로 스크롤이 생긴다). + +--- + ## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지) 운영 쇼핑몰(`miraskitchen`)에서 직접 확인한 내용이다. 문서에 없는 경로를