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 %}
+ 선택한 상품의 상세페이지 HTML 을 여기서 바로 편집하고 카페24에 적용할 수 있습니다.
+ 적용 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.
+
- {% if keyword %}“{{ keyword }}” 로 찾은 상품이 없습니다.{% else %}표시할 상품이 없습니다.{% endif %} -
- {% endif %} +