1+ import io
2+
3+ import pandas
14import pytest
5+ from openpyxl import load_workbook
6+ from openpyxl .utils import get_column_letter
27from 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+ )
416from shop .order .models import Order , OrderProductOptionRelation , OrderProductRelation
517from 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
76153def 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
83160def 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