Skip to content

Commit c6dc7ee

Browse files
committed
fix: 중복 옵션 그룹을 구매한 경우 export 시 제대로 노출되지 않는 문제 수정
1 parent 91d217a commit c6dc7ee

6 files changed

Lines changed: 305 additions & 47 deletions

File tree

‎app/admin_api/serializers/shop/products.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,25 @@ class Meta:
9999

100100
class TagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
101101
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
102+
sold_count = serializers.IntegerField(read_only=True)
102103

103104
class Meta:
104105
model = Tag
105-
fields = COMMON_ADMIN_FIELDS + ("name_ko", "name_en", "stock", "max_quantity_per_user", "leftover_stock")
106+
fields = COMMON_ADMIN_FIELDS + (
107+
"name_ko",
108+
"name_en",
109+
"stock",
110+
"max_quantity_per_user",
111+
"leftover_stock",
112+
"sold_count",
113+
)
106114

107115

108116
class OptionGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedFieldModelSerializer):
109117
class OptionAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedModelSerializer):
110118
id = serializers.UUIDField(required=False, help_text="기존 Option 수정 시 PK 전달, 새로 추가 시 생략")
111119
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
120+
sold_count = serializers.IntegerField(read_only=True)
112121

113122
class Meta:
114123
model = Option
@@ -121,11 +130,24 @@ class Meta:
121130
"additional_price",
122131
"stock",
123132
"leftover_stock",
133+
"sold_count",
124134
)
125135
# group 은 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능.
126136
extra_kwargs = {"group": {"required": False}}
127137
list_serializer_class = InstanceListSerializer
128138

139+
def validate_stock(self, stock: int) -> int:
140+
# stock=0 은 "무제한" sentinel, 판매 이력이 없는 옵션의 음수는 품절 처리 관용구라 둘 다 허용.
141+
# 판매분보다 작은 값만 거절 — leftover_stock 이 음수가 되는 유일한 입력 경로다.
142+
if self.instance is None or stock == 0:
143+
return stock
144+
sold_count: int = self.instance.sold_count
145+
if sold_count and stock < sold_count:
146+
raise serializers.ValidationError(
147+
f"이미 {sold_count}개가 판매된 옵션입니다. 재고는 {sold_count} 이상이거나 0(무제한)이어야 합니다."
148+
)
149+
return stock
150+
129151
options = OptionAdminSerializer(many=True, required=False)
130152

131153
class Meta:
@@ -239,6 +261,7 @@ class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, seria
239261
tag_set = serializers.PrimaryKeyRelatedField(many=True, queryset=Tag.objects.filter_active(), required=False)
240262
tag_set_detail = TagAdminSerializer(many=True, read_only=True, source="tag_set")
241263
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
264+
sold_count = serializers.IntegerField(read_only=True)
242265
current_status = serializers.ChoiceField(choices=Product.CurrentStatus.choices, read_only=True)
243266
image = serializers.PrimaryKeyRelatedField(
244267
queryset=PublicFile.objects.filter_active(),
@@ -271,6 +294,7 @@ class Meta:
271294
"tag_set",
272295
"tag_set_detail",
273296
"leftover_stock",
297+
"sold_count",
274298
"current_status",
275299
)
276300

‎app/admin_api/test/shop/products_api_test.py‎

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
HTTP_403_FORBIDDEN,
1212
)
1313
from shop.conftest import FAR_FUTURE, FAR_PAST
14-
from shop.product.models import Category, CategoryGroup, OptionGroup, Product, Tag
14+
from shop.order.models import OrderProductOptionRelation
15+
from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, Tag
1516

1617
PRODUCT_SELECTABLES_URL = reverse("v1:admin-shop-product-list") + "selectables/"
1718

@@ -489,3 +490,74 @@ def test_admin_product_selectables_include_meta(api_client, ticket_product):
489490
)
490491
# meta_schema 는 모델의 choices_meta_schema 를 반영한다.
491492
assert {"category", "price", "stock", "status"} <= set(body["meta_schema"])
493+
494+
495+
@pytest.fixture
496+
def sold_option(order_factory, option_group) -> Option:
497+
"""paid OPR 2건이 붙은 옵션 — sold_count=2, stock=10."""
498+
sized = option_group.options.create(name="M", stock=10)
499+
for _ in range(2):
500+
OrderProductOptionRelation.objects.create(
501+
order_product_relation=order_factory(status="completed").products.get(),
502+
product_option_group=option_group,
503+
product_option=sized,
504+
)
505+
return sized
506+
507+
508+
def _option_payload(option: Option, *, stock: int) -> dict:
509+
# nested options 는 전량 동기화라 유지할 옵션을 모두 실어야 한다 (누락 시 soft delete).
510+
return {"options": [{"id": str(option.id), "name_ko": option.name_ko, "name_en": option.name_en, "stock": stock}]}
511+
512+
513+
@pytest.mark.django_db
514+
def test_admin_option_update_rejects_stock_below_sold_count(api_client, option_group, sold_option):
515+
# 이미 2개 팔린 옵션에 stock=1 → leftover_stock 이 -1 이 되는 유일한 입력 경로라 거절.
516+
response = OptionGroupsAdminApi(http_client=api_client).update(
517+
option_group.id, _option_payload(sold_option, stock=1)
518+
)
519+
assert response.status_code == HTTP_400_BAD_REQUEST
520+
assert "이미 2개가 판매된 옵션입니다" in str(response.json())
521+
sold_option.refresh_from_db()
522+
assert sold_option.stock == 10
523+
524+
525+
@pytest.mark.django_db
526+
def test_admin_option_update_allows_stock_equal_to_sold_count(api_client, option_group, sold_option):
527+
# 판매 수량과 같은 값 = leftover 0 (판매 마감) — 운영자가 실제로 쓰는 마감 방식이라 허용.
528+
response = OptionGroupsAdminApi(http_client=api_client).update(
529+
option_group.id, _option_payload(sold_option, stock=2)
530+
)
531+
assert response.status_code == HTTP_200_OK
532+
sold_option.refresh_from_db()
533+
assert sold_option.stock == 2
534+
assert sold_option.leftover_stock == 0
535+
536+
537+
@pytest.mark.django_db
538+
def test_admin_option_update_allows_zero_stock_as_unlimited(api_client, option_group, sold_option):
539+
response = OptionGroupsAdminApi(http_client=api_client).update(
540+
option_group.id, _option_payload(sold_option, stock=0)
541+
)
542+
assert response.status_code == HTTP_200_OK
543+
sold_option.refresh_from_db()
544+
assert sold_option.leftover_stock is None
545+
546+
547+
@pytest.mark.django_db
548+
def test_admin_option_update_allows_negative_stock_when_nothing_sold(api_client, option_group, option):
549+
# 판매 이력 없는 옵션을 품절 노출시키는 관용구(-1) — stock=0 이 무제한이라 이 방법뿐이므로 막지 않는다.
550+
response = OptionGroupsAdminApi(http_client=api_client).update(option_group.id, _option_payload(option, stock=-1))
551+
assert response.status_code == HTTP_200_OK
552+
option.refresh_from_db()
553+
assert option.stock == -1
554+
555+
556+
@pytest.mark.django_db
557+
def test_admin_option_group_retrieve_exposes_sold_count(api_client, option_group, sold_option):
558+
# stock=0 이면 leftover_stock 이 null 이라 어드민에서 판매 수량을 볼 수 없었다 — sold_count 로 항상 노출.
559+
response = OptionGroupsAdminApi(http_client=api_client).retrieve(option_group.id)
560+
assert response.status_code == HTTP_200_OK
561+
payload = {o["id"]: o for o in response.json()["options"]}[str(sold_option.id)]
562+
assert payload["sold_count"] == 2
563+
assert payload["leftover_stock"] == 8

‎app/admin_api/views/shop/orders.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,12 @@ def export(self, request: request.Request) -> StreamingHttpResponse:
254254
"주문": exports.OrderExportSerializer(instance=order_qs, many=True).export(),
255255
"주문상품": exports.OrderProductExportSerializer(instance=order_product_qs, many=True).export(),
256256
}
257-
with pandas.ExcelWriter(fileio) as writer:
257+
# engine 명시 — pandas 의 "auto" 는 설치된 패키지에 따라 openpyxl 로 바뀔 수 있고,
258+
# autofit_columns 는 xlsxwriter 의 set_column API 에 의존한다.
259+
with pandas.ExcelWriter(fileio, engine="xlsxwriter") as writer:
258260
for sheet_name, df in df_dict.items():
259261
df.to_excel(writer, sheet_name=sheet_name, startrow=0, startcol=0)
262+
exports.autofit_columns(writer.sheets[sheet_name], df)
260263
return StreamingHttpResponse(
261264
streaming_content=File(fileio),
262265
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",

‎app/shop/order/exports.py‎

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,62 @@
11
import collections.abc
2+
import re
23
import typing
4+
import unicodedata
35

46
import pandas
57
from rest_framework import serializers
68
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
79
from shop.product.models import Option, OptionGroup
810

11+
MIN_COLUMN_WIDTH = 8
12+
MAX_COLUMN_WIDTH = 50
13+
COLUMN_WIDTH_PADDING = 2
14+
15+
# 한 주문 상품이 같은 옵션 그룹을 여러 번 고른 경우의 2번째 이후 컬럼명 — `검은색 티셔츠 (2)`.
16+
NUMBERED_OPTION_COLUMN = re.compile(r"^(?P<name>.+) \((?P<nth>\d+)\)$")
17+
18+
19+
def _display_width(value: object) -> int:
20+
# 엑셀 열 너비는 글자 수 기준이라 한글/전각은 두 칸으로 세야 실제 표시 폭에 맞는다.
21+
return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in str(value))
22+
23+
24+
def autofit_columns(worksheet: typing.Any, df: pandas.DataFrame, *, index: bool = True) -> None:
25+
"""헤더/값의 표시 폭에 맞춰 각 열 너비를 지정한다. `engine="xlsxwriter"` 전용."""
26+
offset = 1 if index else 0
27+
if index:
28+
worksheet.set_column(0, 0, MIN_COLUMN_WIDTH)
29+
30+
for position, column in enumerate(df.columns):
31+
content_width = max(
32+
[_display_width(column), *(_display_width(value) for value in df[column].dropna())],
33+
)
34+
width = min(max(content_width + COLUMN_WIDTH_PADDING, MIN_COLUMN_WIDTH), MAX_COLUMN_WIDTH)
35+
worksheet.set_column(position + offset, position + offset, width)
36+
37+
38+
def _option_column_sort_key(column: str) -> tuple[str, int]:
39+
match = NUMBERED_OPTION_COLUMN.match(column)
40+
return (match["name"], int(match["nth"])) if match else (column, 1)
41+
42+
43+
def _ordered_columns(df: pandas.DataFrame, fixed_labels: collections.abc.Sequence[str]) -> list[str]:
44+
"""고정 컬럼은 field_def 순서, 옵션 그룹 컬럼은 이름순으로 배치한다.
45+
46+
옵션 컬럼은 행마다 동적으로 붙어 기본 순서가 "먼저 등장한 순" — 데이터가 바뀌면 열 순서도 바뀌고
47+
`그룹명 (2)` 가 다른 그룹 뒤로 밀려 수기 집계에서 누락되기 쉽다. (그룹명, n) 정렬로 항상 붙여 둔다.
48+
"""
49+
present = [str(column) for column in df.columns]
50+
fixed = [label for label in fixed_labels if label in present]
51+
dynamic = sorted(set(present) - set(fixed), key=_option_column_sort_key)
52+
return fixed + dynamic
53+
954

1055
class ListExportSerializer(serializers.ListSerializer):
1156
def export(self) -> pandas.DataFrame:
1257
field_def = self.child.Meta.field_def # type: ignore[attr-defined,union-attr]
13-
return pandas.DataFrame(data=self.data).rename(columns=dict(field_def))
58+
df = pandas.DataFrame(data=self.data).rename(columns=dict(field_def))
59+
return df[_ordered_columns(df, [label for _, label in field_def])]
1460

1561

1662
class OrderExportSerializer(serializers.ModelSerializer):
@@ -66,14 +112,28 @@ class Meta:
66112
def to_representation(self, instance: OrderProductRelation) -> dict[str, typing.Any]:
67113
result: dict[str, typing.Any] = super().to_representation(instance)
68114

69-
options: collections.abc.Iterable[OrderProductOptionRelation] = instance.options.filter_active()
115+
options: collections.abc.Iterable[OrderProductOptionRelation] = (
116+
instance.options.filter_active()
117+
.select_related("product_option_group", "product_option")
118+
.order_by("product_option__priority", "created_at", "id")
119+
)
120+
seen_per_group: collections.Counter[str] = collections.Counter()
70121
for option in options:
71122
option_group: OptionGroup = option.product_option_group
72-
selected_option: Option = option.product_option
123+
selected_option: Option | None = option.product_option
73124

74125
name: str = option_group.name
75-
value: str | None = option.custom_response if option_group.is_custom_response else selected_option.name
76-
result[name] = value
126+
value: str | None = (
127+
option.custom_response
128+
if option_group.is_custom_response
129+
else (selected_option.name if selected_option else None)
130+
)
131+
132+
seen_per_group[name] += 1
133+
# 같은 그룹 옵션을 여러 개 고른 주문은 `그룹명`, `그룹명 (2)` … 로 컬럼을 나눈다.
134+
# 한 키에 덮어쓰면 두 번째부터가 사라져 사이즈별 수량 집계가 어긋난다.
135+
nth = seen_per_group[name]
136+
result[name if nth == 1 else f"{name} ({nth})"] = value
77137

78138
return result
79139

‎app/shop/order/test/exports_test.py‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
1+
import io
2+
3+
import pandas
14
import pytest
5+
from openpyxl import load_workbook
6+
from openpyxl.utils import get_column_letter
27
from rest_framework.fields import DateTimeField
3-
from shop.order.exports import OrderExportSerializer, OrderProductExportSerializer
8+
from shop.order.exports import (
9+
COLUMN_WIDTH_PADDING,
10+
MAX_COLUMN_WIDTH,
11+
MIN_COLUMN_WIDTH,
12+
OrderExportSerializer,
13+
OrderProductExportSerializer,
14+
autofit_columns,
15+
)
416
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
517
from shop.product.models import OptionGroup
618

@@ -72,6 +84,71 @@ def test_order_product_export_flattens_options_as_dynamic_columns(ticket_product
7284
]
7385

7486

87+
@pytest.mark.django_db
88+
def test_order_product_export_splits_repeated_options_of_one_group_into_numbered_columns(ticket_product, order_factory):
89+
# 한 주문 상품이 같은 그룹 옵션을 여러 개 고른 경우 — 한 컬럼에 덮어쓰면 두 번째부터 사라져
90+
# 사이즈별 수량 집계가 어긋난다 (재고 음수 사고의 원인).
91+
completed_order = order_factory(status="completed")
92+
size_group = OptionGroup.objects.create(product=ticket_product, name="검은색 티셔츠")
93+
small = size_group.options.create(name="S", priority=0)
94+
large = size_group.options.create(name="L", priority=10)
95+
opr = completed_order.products.first()
96+
for selected in (large, small, large):
97+
OrderProductOptionRelation.objects.create(
98+
order_product_relation=opr, product_option_group=size_group, product_option=selected
99+
)
100+
101+
df = OrderProductExportSerializer(instance=OrderProductRelation.objects.filter(id=opr.id), many=True).export()
102+
row = df.to_dict(orient="records")[0]
103+
# priority 순 정렬이라 S 가 먼저, 그 다음 L 두 벌.
104+
assert row["검은색 티셔츠"] == "S"
105+
assert row["검은색 티셔츠 (2)"] == "L"
106+
assert row["검은색 티셔츠 (3)"] == "L"
107+
108+
109+
@pytest.mark.django_db
110+
def test_order_product_export_orders_option_columns_by_group_name(ticket_product, order_factory):
111+
# 옵션 컬럼은 행마다 동적으로 붙어 기본값이 "먼저 등장한 순" — 데이터 순서에 따라 `흰색 (2)` 가
112+
# `검은색 (3)` 앞으로 끼어들어 같은 그룹이 흩어진다. 고정 컬럼 뒤에 (그룹명, n) 순으로 배치한다.
113+
white = OptionGroup.objects.create(product=ticket_product, name="흰색 티셔츠")
114+
black = OptionGroup.objects.create(product=ticket_product, name="검은색 티셔츠")
115+
for group, counts in ((white, 2), (black, 3)):
116+
opr = order_factory(status="completed").products.get()
117+
selected = group.options.create(name="M")
118+
for _ in range(counts):
119+
OrderProductOptionRelation.objects.create(
120+
order_product_relation=opr, product_option_group=group, product_option=selected
121+
)
122+
123+
df = OrderProductExportSerializer(instance=OrderProductRelation.objects.filter_active(), many=True).export()
124+
fixed = [label for _, label in OrderProductExportSerializer.Meta.field_def]
125+
assert list(df.columns) == [
126+
*fixed,
127+
"검은색 티셔츠",
128+
"검은색 티셔츠 (2)",
129+
"검은색 티셔츠 (3)",
130+
"흰색 티셔츠",
131+
"흰색 티셔츠 (2)",
132+
]
133+
134+
135+
@pytest.mark.django_db
136+
def test_order_product_export_renders_unselected_optional_option_as_none(ticket_product, order_factory):
137+
# placeholder_mode=OPTIONAL 그룹은 product_option 없이 저장될 수 있다 — export 가 터지면 안 된다.
138+
completed_order = order_factory(status="completed")
139+
opr = completed_order.products.first()
140+
OrderProductOptionRelation.objects.create(
141+
order_product_relation=opr,
142+
product_option_group=OptionGroup.objects.create(
143+
product=ticket_product, name="사이즈", placeholder_mode=OptionGroup.PlaceholderMode.OPTIONAL
144+
),
145+
product_option=None,
146+
)
147+
148+
df = OrderProductExportSerializer(instance=OrderProductRelation.objects.filter(id=opr.id), many=True).export()
149+
assert df.to_dict(orient="records")[0]["사이즈"] is None
150+
151+
75152
@pytest.mark.django_db
76153
def test_order_product_export_calling_export_on_child_raises_to_force_list_serializer():
77154
# 단건 OrderProductExportSerializer.export() 는 NotImplemented — `many=True` 강제하는 guard.
@@ -83,3 +160,25 @@ def test_order_product_export_calling_export_on_child_raises_to_force_list_seria
83160
def test_order_export_calling_export_on_child_raises():
84161
with pytest.raises(NotImplementedError):
85162
OrderExportSerializer().export()
163+
164+
165+
def test_autofit_columns_sizes_each_column_to_its_widest_cell():
166+
long_header = "긴한글헤더입니다"
167+
df = pandas.DataFrame({"짧음": ["a"], long_header: ["x"], "값이긴열": ["가" * 40]})
168+
fileio = io.BytesIO()
169+
with pandas.ExcelWriter(fileio, engine="xlsxwriter") as writer:
170+
df.to_excel(writer, sheet_name="s")
171+
autofit_columns(writer.sheets["s"], df)
172+
173+
# xlsxwriter 는 같은 너비의 인접 열을 하나의 range 로 묶어 쓰므로 min~max 로 펼쳐서 읽는다.
174+
# 되읽은 너비에는 폰트 보정분(~0.71)이 붙어 abs=1 로 비교.
175+
widths = {
176+
get_column_letter(index): dim.width
177+
for dim in load_workbook(fileio)["s"].column_dimensions.values()
178+
for index in range(dim.min, dim.max + 1)
179+
}
180+
assert widths["A"] == pytest.approx(MIN_COLUMN_WIDTH, abs=1) # index 열
181+
assert widths["B"] == pytest.approx(MIN_COLUMN_WIDTH, abs=1) # 헤더 "짧음" 폭 4 → 하한
182+
# 헤더가 값보다 넓으면 헤더 기준. 한글은 2칸으로 센다.
183+
assert widths["C"] == pytest.approx(len(long_header) * 2 + COLUMN_WIDTH_PADDING, abs=1)
184+
assert widths["D"] == pytest.approx(MAX_COLUMN_WIDTH, abs=1) # 값 폭 80 → 상한

0 commit comments

Comments
 (0)