Skip to content

Commit ff8f92b

Browse files
committed
feat: 주문 export의 필터 추가
1 parent b8a50fc commit ff8f92b

7 files changed

Lines changed: 106 additions & 28 deletions

File tree

app/admin_api/filtersets/shop/orders.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class OrderAdminFilterSet(filters.FilterSet):
3030
product_id = filters.BaseCSVFilter(method="filter_by_active_opr_product_id")
3131
category_id = filters.BaseCSVFilter(method="filter_by_active_opr_category_id")
3232
category_group_id = filters.BaseCSVFilter(method="filter_by_active_opr_category_group_id")
33+
event_id = filters.BaseCSVFilter(method="filter_by_active_opr_event_id")
3334

3435
price_min = filters.NumberFilter(field_name="latest_price", lookup_expr="gte")
3536
price_max = filters.NumberFilter(field_name="latest_price", lookup_expr="lte")
@@ -46,6 +47,9 @@ def filter_by_active_opr_category_id(self, qs: QuerySet[Order], n: str, vs: list
4647
def filter_by_active_opr_category_group_id(self, qs: QuerySet[Order], n: str, v: list[str]) -> QuerySet[Order]:
4748
return self._filter_by_active_opr_exists(qs, product__category__group_id__in=v) if v else qs
4849

50+
def filter_by_active_opr_event_id(self, qs: QuerySet[Order], n: str, vs: list[str]) -> QuerySet[Order]:
51+
return self._filter_by_active_opr_exists(qs, product__category__event_id__in=vs) if vs else qs
52+
4953
class Meta:
5054
model = Order
5155
fields = [
@@ -65,6 +69,7 @@ class Meta:
6569
"product_id",
6670
"category_id",
6771
"category_group_id",
72+
"event_id",
6873
"price_min",
6974
"price_max",
7075
]

app/admin_api/serializers/shop/orders.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@ def update(self, instance: Order, validated_data: dict) -> Order:
121121

122122

123123
class OrderExportRequestSerializer(JsonSchemaSerializer, serializers.Serializer):
124-
product_ids = serializers.ListField(child=serializers.UUIDField(), required=True, min_length=1)
125124
include_refunded = serializers.BooleanField(default=False)
126125

127126

app/admin_api/test/helpers.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import ClassVar
2+
from urllib.parse import urlencode
23

34
from core.util.testutil import ModelApiFixture
45
from django.urls import reverse
@@ -22,8 +23,11 @@ def import_csv(self, *, csv_file=None):
2223
data = {"csv_file": csv_file} if csv_file is not None else {}
2324
return self.http_client.post(reverse(f"{self.name}-import-csv"), data, format="multipart")
2425

25-
def export(self, data=None):
26-
return self.http_client.post(reverse(f"{self.name}-export"), data, format="json")
26+
def export(self, params=None):
27+
url = reverse(f"{self.name}-export")
28+
if params:
29+
url = f"{url}?{urlencode(params, doseq=True)}"
30+
return self.http_client.post(url, format="json")
2731

2832

2933
class OrderNotificationsAdminApi(ModelApiFixture):

app/admin_api/test/shop/orders_api_test.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from admin_api.test.helpers import OrdersAdminApi
88
from admin_api.views.shop.orders import OrderAdminViewSet
99
from freezegun import freeze_time
10+
from model_bakery import baker
1011
from rest_framework.fields import DateTimeField
1112
from rest_framework.status import (
1213
HTTP_200_OK,
@@ -101,6 +102,21 @@ def test_admin_list_filters_by_active_opr_category_group(api_client, ticket_prod
101102
assert {row["id"] for row in response.json()["results"]} == {str(completed_order.id)}
102103

103104

105+
@pytest.mark.django_db
106+
def test_admin_list_filters_by_active_opr_event(api_client, ticket_product, non_ticket_product, order_factory):
107+
"""`?event_id=` 가 해당 이벤트 카테고리의 상품을 가진 주문만 매칭한다."""
108+
event = baker.make("event.Event", name="파이콘 한국 2026")
109+
ticket_product.category.event = event
110+
ticket_product.category.save()
111+
112+
in_event_order = order_factory(status="completed") # ticket_product → event 연결됨
113+
order_factory(status="completed", is_ticket=False) # non_ticket_product → event 없음
114+
115+
response = OrdersAdminApi(http_client=api_client).list({"event_id": str(event.id)})
116+
assert response.status_code == HTTP_200_OK
117+
assert {row["id"] for row in response.json()["results"]} == {str(in_event_order.id)}
118+
119+
104120
@pytest.mark.django_db
105121
def test_admin_retrieve_returns_nested_payload(api_client, order_factory):
106122
completed_order = order_factory(status="completed")
@@ -226,7 +242,7 @@ def test_admin_export_returns_xlsx_filtering_refunded_per_include_flag(
226242
):
227243
refunded_order = order_factory(status="refunded")
228244
response = OrdersAdminApi(http_client=api_client).export(
229-
{"product_ids": [str(ticket_product.id)], "include_refunded": include_refunded}
245+
{"product_id": str(ticket_product.id), "include_refunded": include_refunded}
230246
)
231247
assert response.status_code == HTTP_200_OK
232248
assert response.headers["Content-Type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@@ -281,11 +297,49 @@ def test_admin_export_returns_xlsx_filtering_refunded_per_include_flag(
281297
]
282298

283299

284-
@pytest.mark.parametrize("payload", [{}, {"product_ids": []}])
300+
def _export_order_ids(response) -> set[str]:
301+
"""export XLSX 응답의 '주문' 시트에서 주문 번호 집합을 추출."""
302+
df = pandas.read_excel(BytesIO(b"".join(response.streaming_content)), sheet_name="주문", index_col=0, dtype=str)
303+
return set(df["주문 번호"].tolist()) if not df.empty else set()
304+
305+
285306
@pytest.mark.django_db
286-
def test_admin_export_rejects_missing_or_empty_product_ids(api_client, payload):
287-
response = OrdersAdminApi(http_client=api_client).export(payload)
288-
assert response.status_code == HTTP_400_BAD_REQUEST
307+
def test_admin_export_without_filters_returns_all_purchased_orders(api_client, ticket_product, order_factory):
308+
"""필터 없이 호출하면 결제 완료 주문 전체를 내보낸다 (기본 include_refunded=false → 환불 제외)."""
309+
completed_order = order_factory(status="completed")
310+
order_factory(status="refunded") # include_refunded 기본 false → 제외
311+
response = OrdersAdminApi(http_client=api_client).export()
312+
assert response.status_code == HTTP_200_OK
313+
# streaming_content 는 1회성 iterator — 한 번만 읽어 비교 (== 비교가 환불 주문 제외도 함께 검증).
314+
assert _export_order_ids(response) == {str(completed_order.id)}
315+
316+
317+
@pytest.mark.django_db
318+
def test_admin_export_scopes_by_event_id(api_client, ticket_product, non_ticket_product, order_factory):
319+
"""`?event_id=` 가 해당 이벤트 카테고리의 상품을 가진 주문만 내보낸다."""
320+
event = baker.make("event.Event", name="파이콘 한국 2026")
321+
ticket_product.category.event = event
322+
ticket_product.category.save()
323+
324+
in_event_order = order_factory(status="completed") # ticket_product (event 연결됨)
325+
order_factory(status="completed", is_ticket=False) # non_ticket_product (event 없음)
326+
327+
response = OrdersAdminApi(http_client=api_client).export({"event_id": str(event.id)})
328+
assert response.status_code == HTTP_200_OK
329+
assert _export_order_ids(response) == {str(in_event_order.id)}
330+
331+
332+
@pytest.mark.django_db
333+
def test_admin_export_scopes_by_category_group_id(api_client, ticket_product, non_ticket_product, order_factory):
334+
"""`?category_group_id=` 가 해당 그룹 상품을 가진 주문만 내보낸다."""
335+
ticket_order = order_factory(status="completed")
336+
order_factory(status="completed", is_ticket=False)
337+
338+
response = OrdersAdminApi(http_client=api_client).export(
339+
{"category_group_id": str(ticket_product.category.group_id)}
340+
)
341+
assert response.status_code == HTTP_200_OK
342+
assert _export_order_ids(response) == {str(ticket_order.id)}
289343

290344

291345
@pytest.mark.django_db

app/admin_api/test/shop/products_api_test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ def test_admin_category_group_create_rejects_duplicate_name(api_client):
4242
assert response.status_code == HTTP_400_BAD_REQUEST
4343

4444

45+
@pytest.mark.django_db
46+
def test_admin_category_group_selectables_include_priority_meta(api_client):
47+
# selectables 의 각 그룹은 CategoryGroup.get_choice_meta() 로 priority 메타를 실어야 한다.
48+
group = CategoryGroup.objects.create(name="굿즈", priority=7)
49+
url = reverse("v1:admin-shop-category-group-list") + "selectables/"
50+
response = api_client.get(url)
51+
assert response.status_code == HTTP_200_OK
52+
body = response.json()
53+
assert {c["const"]: c for c in body["results"]}[str(group.id)]["meta"]["priority"] == 7
54+
assert "priority" in body["meta_schema"]
55+
56+
4557
def _patch_category(api_client, category: Category, **fields) -> object:
4658
# 카테고리는 CategoryGroup nested 로만 수정 — 그룹에 카테고리 1개뿐이므로 단건 전송이 전체 목록.
4759
return CategoryGroupsAdminApi(http_client=api_client).update(

app/admin_api/test/shop/soft_delete_filtering_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def test_admin_export_excludes_soft_deleted_opr_from_product_sheet(api_client, o
4444
stale_opr.delete()
4545

4646
response = OrdersAdminApi(http_client=api_client).export(
47-
{"product_ids": [str(ticket_product.id)], "include_refunded": False}
47+
{"product_id": str(ticket_product.id), "include_refunded": False}
4848
)
4949
assert response.status_code == HTTP_200_OK
5050
df_dict = pandas.read_excel(
@@ -101,7 +101,7 @@ def test_admin_export_excludes_order_with_only_soft_deleted_matching_opr(
101101
PaymentHistory.objects.create(order=leak_candidate, imp_id="leak", status=PaymentHistoryStatus.completed, price=500)
102102

103103
response = OrdersAdminApi(http_client=api_client).export(
104-
{"product_ids": [str(target_product.id)], "include_refunded": True}
104+
{"product_id": str(target_product.id), "include_refunded": True}
105105
)
106106
assert response.status_code == HTTP_200_OK
107107
df_dict = pandas.read_excel(

app/admin_api/views/shop/orders.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,7 @@ def refund_product(
135135
@extend_schema(
136136
summary="주문 CSV 가져오기 템플릿 다운로드",
137137
tags=[OpenAPITag.ADMIN_SHOP_ORDER],
138-
parameters=[
139-
OpenApiParameter(name="product_id", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True),
140-
],
138+
parameters=[OpenApiParameter(name="product_id", type=OpenApiTypes.UUID, required=True)],
141139
responses={status.HTTP_200_OK: OpenApiTypes.STR},
142140
)
143141
@action(detail=False, methods=["get"], url_path="import-template")
@@ -196,32 +194,38 @@ def import_csv(self, request: request.Request) -> response.Response:
196194
@extend_schema(
197195
summary="주문 XLSX 내보내기",
198196
tags=[OpenAPITag.ADMIN_SHOP_ORDER],
199-
request=OrderExportRequestSerializer,
197+
parameters=[
198+
OpenApiParameter(name="event_id", description="이벤트 ID (CSV 다중값)"),
199+
OpenApiParameter(name="category_group_id", description="카테고리 그룹 ID (CSV 다중값)"),
200+
OpenApiParameter(name="category_id", description="카테고리 ID (CSV 다중값)"),
201+
OpenApiParameter(name="product_id", description="상품 ID (CSV 다중값)"),
202+
OpenApiParameter(
203+
name="include_refunded", type=OpenApiTypes.BOOL, description="환불 주문 포함 여부 (기본 false)"
204+
),
205+
],
206+
request=None,
200207
responses={status.HTTP_200_OK: OpenApiTypes.BINARY},
201208
)
202209
@action(detail=False, methods=["post"], url_path="export")
203210
def export(self, request: request.Request) -> StreamingHttpResponse:
204-
req = OrderExportRequestSerializer(data=request.data)
211+
req = OrderExportRequestSerializer(data=request.query_params)
205212
req.is_valid(raise_exception=True)
206-
product_ids = req.validated_data["product_ids"]
207-
include_refunded = req.validated_data["include_refunded"]
208-
209-
statuses = PURCHASED_STATUSES if include_refunded else REFUNDABLE_STATUSES
213+
statuses = PURCHASED_STATUSES if req.validated_data["include_refunded"] else REFUNDABLE_STATUSES
210214

211-
order_qs = (
215+
base_qs = (
212216
Order.objects.filter_active()
213-
.annotate(current_status=PaymentHistory.objects.latest_per_order_field("status"))
214217
.select_related("user")
215218
.with_dto_prefetches()
216-
.filter(
217-
models.Exists(
218-
OrderProductRelation.objects.filter_active().filter(
219-
order_id=models.OuterRef("pk"), product_id__in=product_ids
220-
)
221-
),
222-
current_status__in=statuses,
219+
.annotate(
220+
current_status=PaymentHistory.objects.latest_per_order_field("status"),
221+
latest_imp_id=PaymentHistory.objects.latest_per_order_field("imp_id"),
222+
latest_price=PaymentHistory.objects.latest_per_order_field("price"),
223+
first_paid_at=_payment_history_created_at_subquery(latest=False),
224+
status_changed_at=_payment_history_created_at_subquery(latest=True),
223225
)
226+
.filter(current_status__in=statuses)
224227
)
228+
order_qs = OrderAdminFilterSet(request.query_params, queryset=base_qs, request=request).qs
225229
order_product_qs = (
226230
OrderProductRelation.objects.filter_active()
227231
.filter(order__in=order_qs)

0 commit comments

Comments
 (0)