Files
dbx-corm/routers/mall_event.py
T
king 065e81bf97 feat: v9.0 업그레이드 — CS 작업 탭 개편 + 밀크런/자사몰 행사 모듈
CS 통합 프로그램.ZIP(로컬 개발본 v9.0)의 기능 소스를 반영.
DB 스키마는 기존과 동일해 마이그레이션 없이 그대로 사용한다.

가져온 것
- main.py / cafe24_api.py / templates / static: v9.0 기능 코드
- routers/coupang_milkrun.py, routers/mall_event.py (신규)
- static/js/mall_event.js, static/js/milkrun_gsheet.js (신규)

운영 설정은 기존 것을 유지·재적용
- DB/카페24/네이버 접속정보를 하드코딩 대신 환경변수 기반으로 복원
- SSO(AuthGuardMiddleware, SessionMiddleware), /login, /logout, /health/db 복원
- APP_ROOT_PATH 서브경로 호스팅(root_path 템플릿 변수, app.js fetch 래퍼) 복원
- 구글시트 설정 인메모리 캐시(TTL 10분)와 /api/config/refresh 복원

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:56:17 +09:00

1124 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""자사몰 행사 - 카페24 주문/발주 파일 가공 모듈.
카페24에서 받은 파일을 업로드하면 행사 조건을 적용해
가공된 파일을 다시 내려받을 수 있게 한다.
현재 제공 기능
1) 선착순 사은품 추가 (발주주문서 xlsx 업로드 → 사은품 행이 추가된 xlsx)
2) 행사 주문 추출 (주문 CSV 업로드 → 조건에 맞는 행만 남긴 xlsx)
앞으로 다른 가공 기능(업로드 → 분석 → 변환 파일 다운로드)이 이 라우터에 계속 추가된다.
새 기능을 넣을 때는 `_first_come_*`, `_event_extract_*` 처럼 기능 접두사를 붙여 헬퍼를
분리하고, 엔드포인트 경로도 `/api/mall-event/<기능명>/...` 형태로 나눈다.
성능 메모
분석/미리보기는 값만 있으면 되므로 openpyxl read_only 모드로 한 번만 훑는다.
서식을 유지해야 하는 다운로드 단계에서만 쓰기 가능한 워크북을 연다.
같은 파일을 분석 → 미리보기 → 다운로드로 연달아 올리므로,
파일 내용 해시를 키로 스캔 결과를 짧게 캐싱해 재파싱을 피한다.
[기능 1] 발주주문서 열 위치 (카페24 양식 기준, 사용자 확정)
A: 주문날짜 C: 품목 순번(끝 숫자 증가 대상) E: 아이템코드
F: 상품이름 G: 수량 P: 주문번호 Q: 주문목록
[기능 2] 주문 CSV 열 위치 (사용자 확정)
B: 주문번호 K: 주문상품명(세트상품 포함) L, M: N열 수식의 피연산자
N: L×M 수식을 넣는 열
"""
import csv
import hashlib
import io
import json
import re
import time
import urllib.parse
from bisect import bisect_left
from collections import OrderedDict
from copy import copy
from datetime import date, datetime
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
router = APIRouter(prefix="/api/mall-event", tags=["Mall Event"])
# --- 발주주문서 열 위치 (1-based) ---
COL_ORDER_DATE = 1 # A 주문날짜
COL_SEQ = 3 # C 품목 순번
COL_ITEM_CODE = 5 # E 아이템코드
COL_ITEM_NAME = 6 # F 상품이름
COL_QTY = 7 # G 수량
COL_ORDER_NO = 16 # P 주문번호
COL_ORDER_LIST = 17 # Q 주문목록
COL_LAST = COL_ORDER_LIST # 사은품 행에 색을 칠하는 범위: A~Q
GIFT_LABEL = "선착순 사은품"
YELLOW_FILL = PatternFill(start_color="FFFFFF00", end_color="FFFFFF00", fill_type="solid")
# --- 주문 CSV 열 위치 (1-based) ---
EE_COL_ORDER_NO = 2 # B 주문번호
EE_COL_PRODUCT = 11 # K 주문상품명(세트상품 포함)
EE_COL_L = 12 # L (N열 수식의 왼쪽 피연산자)
EE_COL_M = 13 # M (N열 수식의 오른쪽 피연산자)
EE_COL_N = 14 # N (=L×M 수식을 넣는 열)
EE_SPLIT_TOKEN = " -" # 주문상품명에서 이 문자열 앞까지를 상품명으로 본다
# --- 추출 결과 파일 서식 범위 (1-based) ---
EE_FORMAT_LAST_COL = 28 # AB. 제목행 서식/자동필터/열너비를 적용하는 마지막 열
EE_NUM_FIRST_COL = 12 # L. 숫자 변환 + 쉼표 서식 + 0은 빈 셀 처리 시작
EE_NUM_LAST_COL = 19 # S. 위 처리의 마지막 열
EE_DEDUPE_FIRST_COL = 15 # O. 같은 주문번호에서 중복 값을 지우는 범위 시작
EE_DEDUPE_LAST_COL = 19 # S. 위 처리의 마지막 열
EE_SORT_COL = 4 # D. 이 열 기준으로 텍스트 오름차순 정렬
EE_COMMA_FORMAT = "#,##0"
EE_COMMA_FORMAT_DECIMAL = "#,##0.##"
# 주문번호가 바뀔 때마다 두 색을 번갈아 칠해 주문 묶음을 눈으로 구분한다
EE_GROUP_FILLS = (
PatternFill(start_color="FFB4C6E7", end_color="FFB4C6E7", fill_type="solid"),
PatternFill(start_color="FFFFD966", end_color="FFFFD966", fill_type="solid"),
)
EE_HEADER_FILL = PatternFill(start_color="FF3182CE", end_color="FF3182CE", fill_type="solid")
EE_HEADER_FONT = Font(bold=True, color="FFFFFFFF")
EE_HEADER_ALIGN = Alignment(horizontal="center", vertical="center", wrap_text=False)
EE_MIN_COL_WIDTH = 8
EE_MAX_COL_WIDTH = 60
# 길고 장황한 카페24 제목을 짧게 바꾼다 (공백 차이는 무시하고 비교)
EE_HEADER_RENAMES = {
"결제일시(입금확인일)": "결제일시",
"총 배송비 (전체 품목에 표시)": "총 배송비",
"쿠폰 할인금액(최종)": "쿠폰 할인",
"사용한 적립금액(최종)": "사용 적립금",
"네이버 포인트": "N포인트",
"총 실결제금액(최초정보)": "총 실결제금액",
}
_EE_HEADER_RENAMES_LOOSE = {
re.sub(r"\s+", "", key): value for key, value in EE_HEADER_RENAMES.items()
}
# 픽셀 → 엑셀 열 너비 환산 기준 (사용자 확인: 145px = 너비 17.5)
EE_PIXELS_PER_WIDTH_UNIT = 8
EE_WIDTH_PADDING_PIXELS = 5
# 지정한 열은 픽셀 단위로 고정, 나머지는 내용에 맞춰 자동
EE_MANUAL_COL_PIXELS = {
1: 145, # A
2: 135, # B
3: 155, # C
4: 150, # D
5: 80, # E
6: 110, # F
7: 120, # G
8: 120, # H
12: 60, # L
}
EE_MANUAL_COL_PIXELS.update({col: 110 for col in range(13, 21)}) # M~T
# 헤더 행을 찾을 때 훑어볼 최대 행 수 (양식 위에 안내문이 몇 줄 있어도 잡히도록)
_HEADER_SCAN_ROWS = 15
# 업로드 → 미리보기 → 다운로드가 같은 파일로 이어지므로 스캔 결과를 잠시 들고 있는다.
_SCAN_CACHE_MAX = 4
_SCAN_CACHE_TTL_SECONDS = 600
_scan_cache = OrderedDict() # sha1 -> (저장시각, 스캔결과)
_DATE_FORMATS = (
"%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d",
"%Y.%m.%d %H:%M:%S", "%Y.%m.%d %H:%M", "%Y.%m.%d",
"%Y/%m/%d %H:%M:%S", "%Y/%m/%d %H:%M", "%Y/%m/%d",
"%Y%m%d%H%M%S", "%Y%m%d",
)
# =====================================================================
# 공용 헬퍼
# =====================================================================
def cell_text(value):
"""셀 값을 비교/표시에 쓸 문자열로 정규화."""
if value is None:
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, float) and value.is_integer():
return str(int(value))
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
return str(value).strip()
def parse_order_date(value):
"""주문날짜 셀을 datetime으로. 해석 불가면 None."""
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime(value.year, value.month, value.day)
text = cell_text(value)
if not text:
return None
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None
def first_segment(order_list_value):
"""주문목록 텍스트에서 첫 번째 '/' 앞부분만 잘라낸다."""
text = cell_text(order_list_value)
if not text:
return ""
return text.split("/")[0].strip()
def increment_trailing_number(value):
"""끝에 붙은 숫자를 한 단계 올린다. ('...-01''...-02', 3 → 4)
반환: (새 값, 성공 여부). 끝 숫자가 없으면 원본을 그대로 돌려주고 False.
"""
if value is None:
return None, False
if isinstance(value, bool):
return value, False
if isinstance(value, int):
return value + 1, True
if isinstance(value, float):
return (int(value) + 1) if value.is_integer() else value + 1, True
text = str(value)
matches = list(re.finditer(r"\d+", text))
if not matches:
return text, False
last = matches[-1]
digits = last.group(0)
bumped = str(int(digits) + 1).zfill(len(digits))
return text[:last.start()] + bumped + text[last.end():], True
def _validate_extension(filename):
name = (filename or "").lower()
if name.endswith(".xls"):
raise HTTPException(
status_code=400,
detail="구형 .xls 파일은 서식을 유지한 채 가공할 수 없습니다. "
"엑셀에서 '다른 이름으로 저장 → .xlsx'로 변환한 뒤 다시 업로드해주세요.",
)
if not name.endswith((".xlsx", ".xlsm")):
raise HTTPException(status_code=400, detail="엑셀 파일(.xlsx)만 업로드할 수 있습니다.")
def _value_at(values, column):
return values[column - 1] if len(values) >= column else None
def _is_header_values(values):
order_list_label = cell_text(_value_at(values, COL_ORDER_LIST))
order_date_label = cell_text(_value_at(values, COL_ORDER_DATE))
return "주문목록" in order_list_label or "주문날짜" in order_date_label
def excel_response(workbook, filename, extra_headers=None):
stream = io.BytesIO()
workbook.save(stream)
stream.seek(0)
encoded = urllib.parse.quote(filename)
headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
if extra_headers:
headers.update(extra_headers)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers=headers,
)
def _base_name(original_filename):
"""업로드된 파일 이름에서 경로와 확장자를 떼어낸다."""
base = (original_filename or "발주서").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
for ext in (".xlsx", ".xlsm", ".xls", ".csv"):
if base.lower().endswith(ext):
return base[: -len(ext)]
return base
def _output_name(original_filename, suffix):
return f"{_base_name(original_filename)}_{suffix}.xlsx"
def _same_name(original_filename):
"""업로드한 파일과 같은 이름으로 내려준다. (저장 형식이 xlsx이므로 확장자만 맞춤)"""
return f"{_base_name(original_filename)}.xlsx"
# =====================================================================
# 발주서 스캔 (read_only 1회 통과 + 해시 캐시)
# =====================================================================
def _cache_get(key):
entry = _scan_cache.get(key)
if entry is None:
return None
saved_at, scan = entry
if time.monotonic() - saved_at > _SCAN_CACHE_TTL_SECONDS:
_scan_cache.pop(key, None)
return None
_scan_cache.move_to_end(key)
return scan
def _cache_put(key, scan):
_scan_cache[key] = (time.monotonic(), scan)
_scan_cache.move_to_end(key)
while len(_scan_cache) > _SCAN_CACHE_MAX:
_scan_cache.popitem(last=False)
def _collect_row(row_index, values, orders, sequence):
"""한 행을 주문 묶음에 반영. 내용이 있는 행이면 True."""
if not any(v is not None and str(v).strip() != "" for v in values):
return False
order_no = cell_text(_value_at(values, COL_ORDER_NO))
segment = first_segment(_value_at(values, COL_ORDER_LIST))
if not order_no and not segment:
return True # 합계행 등: 데이터 범위에는 포함하되 주문으로 세지 않는다
key = order_no or f"__행{row_index}"
info = orders.get(key)
if info is None:
info = {
"key": key,
"order_no": order_no,
"first_row": row_index,
"last_row": row_index,
"order_date": parse_order_date(_value_at(values, COL_ORDER_DATE)),
"order_date_text": cell_text(_value_at(values, COL_ORDER_DATE)),
"seq_value": _value_at(values, COL_SEQ), # 마지막 행 값으로 계속 덮어씀
"segments": [],
"rows": [],
}
orders[key] = info
sequence.append(key)
else:
info["last_row"] = row_index
info["seq_value"] = _value_at(values, COL_SEQ)
if info["order_date"] is None:
parsed = parse_order_date(_value_at(values, COL_ORDER_DATE))
if parsed is not None:
info["order_date"] = parsed
info["order_date_text"] = cell_text(_value_at(values, COL_ORDER_DATE))
info["rows"].append(row_index)
if segment and segment not in info["segments"]:
info["segments"].append(segment)
return True
def scan_order_sheet(file_bytes, filename):
"""발주서를 값만 읽어 주문 단위로 묶는다. 같은 파일은 캐시에서 즉시 반환."""
cache_key = "first-come:" + hashlib.sha1(file_bytes).hexdigest()
cached = _cache_get(cache_key)
if cached is not None:
return cached
_validate_extension(filename)
try:
workbook = load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"엑셀 파일을 열 수 없습니다: {exc}")
try:
sheet = workbook.active
sheet_name = sheet.title
rows_iter = sheet.iter_rows(min_col=1, max_col=COL_LAST, values_only=True)
# 헤더 후보 구간만 먼저 버퍼링해서 헤더 행을 정한다
buffered = []
header_row = 1
for index, values in enumerate(rows_iter, start=1):
buffered.append((index, values))
if _is_header_values(values):
header_row = index
break
if index >= _HEADER_SCAN_ROWS:
break
orders = {}
sequence = []
last_row = header_row
for index, values in buffered:
if index > header_row and _collect_row(index, values, orders, sequence):
last_row = index
for index, values in enumerate(rows_iter, start=len(buffered) + 1):
if _collect_row(index, values, orders, sequence):
last_row = index
finally:
workbook.close()
if last_row <= header_row:
raise HTTPException(status_code=400, detail="발주 데이터가 없는 파일입니다. 파일을 확인해주세요.")
counts = {}
for key in sequence:
for segment in orders[key]["segments"]:
counts[segment] = counts.get(segment, 0) + 1
scan = {
"sheet_name": sheet_name,
"header_row": header_row,
"last_row": last_row,
"orders": orders,
"sequence": sequence,
"items": [{"name": name, "order_count": count} for name, count in sorted(counts.items())],
}
_cache_put(cache_key, scan)
return scan
# =====================================================================
# 기능 1) 선착순 사은품 추가
# =====================================================================
def _first_come_sort_key(info):
"""주문날짜 오름차순. 날짜를 못 읽은 주문은 뒤로 보내고 파일 등장 순서를 따른다."""
order_date = info["order_date"]
if order_date is None:
return (1, 0.0, info["first_row"])
return (0, order_date.timestamp(), info["first_row"])
def _first_come_plan(scan, selected_names, limit):
"""선착순 대상 주문과 사은품 행이 들어갈 위치를 계산한다. (파일을 열지 않는다)
사은품 행은 그 주문의 마지막 행 바로 아래에 삽입되므로,
위쪽에 먼저 삽입된 행 수만큼 아래 행들의 최종 번호가 밀린다.
"""
selected = set(selected_names)
matched = []
for key in scan["sequence"]:
info = scan["orders"][key]
hits = [s for s in info["segments"] if s in selected]
if hits:
matched.append((info, hits))
matched.sort(key=lambda pair: _first_come_sort_key(pair[0]))
picked = matched[:limit]
# 삽입 위치(원본 좌표) 오름차순 = 위에서 아래 순서
by_position = sorted(picked, key=lambda pair: pair[0]["last_row"])
position_of = {pair[0]["key"]: i for i, pair in enumerate(by_position)}
insert_rows_sorted = [pair[0]["last_row"] for pair in by_position]
def shifted(original_row):
"""앞서 삽입된 행 수를 반영한 최종 행 번호."""
return original_row + bisect_left(insert_rows_sorted, original_row)
plan = []
for info, hits in picked: # 선착순(날짜) 순서 유지
rank = position_of[info["key"]]
new_seq, seq_ok = increment_trailing_number(info["seq_value"])
plan.append({
"key": info["key"],
"order_no": info["order_no"],
"order_date": info["order_date_text"],
"source_row": info["last_row"],
"new_row": info["last_row"] + 1 + rank,
"seq": cell_text(new_seq),
"seq_ok": seq_ok,
"rows": info["rows"],
"matched": hits,
})
gift_order_rows = set()
for entry in plan:
for row in entry["rows"]:
gift_order_rows.add(shifted(row))
gift_order_rows.add(entry["new_row"])
return {
"matched_order_count": len(matched),
"plan": plan,
"gift_order_rows": sorted(gift_order_rows),
"final_last_row": scan["last_row"] + len(plan),
}
def _first_come_warnings(result, limit):
warnings = []
matched_total = result["matched_order_count"]
if matched_total == 0:
warnings.append("선택한 주문목록에 해당하는 주문을 찾지 못했습니다.")
elif matched_total < limit:
warnings.append(
f"조건에 맞는 주문이 {matched_total}건뿐이라 선착순 {limit}명을 채우지 못했습니다."
)
no_seq = [entry["order_no"] for entry in result["plan"] if not entry["seq_ok"]]
if no_seq:
preview = ", ".join(no_seq[:3])
more = f"{len(no_seq) - 3}" if len(no_seq) > 3 else ""
warnings.append(f"C열에 끝 숫자가 없어 순번을 올리지 못한 주문이 있습니다: {preview}{more}")
return warnings
def _bulk_shift_rows(sheet, insert_points):
"""insert_points(원본 행 번호)들의 '바로 아래'에 빈 행 한 줄씩을 한 번에 만든다.
openpyxl의 insert_rows는 호출할 때마다 아래쪽 셀 전체를 리스트로 만들고 정렬하므로
(Worksheet._move_cells), 삽입 지점마다 부르면 O(삽입건수 × 전체셀)이 되어 매우 느리다.
삽입 지점을 모아 한 번만 훑으면서 각 셀의 최종 행 번호를 계산해 옮긴다.
행 높이(row_dimensions)는 insert_rows도 옮겨주지 않으므로 여기서 같이 처리한다.
"""
points = sorted(insert_points)
if not points:
return
def shift_of(row):
return bisect_left(points, row) # 이 행보다 위에서 삽입된 행 수
moved_cells = {}
for (row, column), cell in sheet._cells.items():
shift = shift_of(row)
if shift:
row += shift
cell.row = row
moved_cells[(row, column)] = cell
sheet._cells = moved_cells
sheet._current_row = max((r for r, _c in moved_cells), default=0)
# 행 높이도 같은 규칙으로 이동 (없으면 건너뜀)
dimensions = sheet.row_dimensions
saved = [
(row, dim.height, dim.hidden, dim.customHeight)
for row, dim in dimensions.items()
]
if saved:
dimensions.clear()
for row, height, hidden, custom_height in saved:
new_row = row + shift_of(row)
dim = dimensions[new_row]
dim.height = height
dim.hidden = hidden
dim.customHeight = custom_height
# 새로 생긴 행은 바로 위(복사 원본) 행의 높이를 따라간다
for rank, point in enumerate(points):
source_row = point + rank
if source_row in dimensions and dimensions[source_row].height is not None:
target = dimensions[source_row + 1]
target.height = dimensions[source_row].height
target.customHeight = dimensions[source_row].customHeight
def _first_come_write(sheet, plan_result, gift):
"""계획대로 각 주문의 마지막 행 아래에 사은품 행을 끼워 넣는다."""
plan = plan_result["plan"]
if not plan:
return
# 먼저 필요한 빈 행을 한 번에 확보한다. 이후 각 사은품 행의 위치는
# 계획 단계에서 계산해 둔 new_row와 정확히 일치한다 (원본 행은 new_row - 1).
_bulk_shift_rows(sheet, [entry["source_row"] for entry in plan])
for entry in plan:
target_row = entry["new_row"]
source_row = target_row - 1
for col in range(1, COL_LAST + 1):
source_cell = sheet.cell(row=source_row, column=col)
target_cell = sheet.cell(row=target_row, column=col)
target_cell.value = source_cell.value
if source_cell.has_style:
target_cell._style = copy(source_cell._style)
seq_value, _seq_ok = increment_trailing_number(sheet.cell(row=source_row, column=COL_SEQ).value)
sheet.cell(row=target_row, column=COL_SEQ).value = seq_value
sheet.cell(row=target_row, column=COL_ITEM_CODE).value = gift["item_code"]
sheet.cell(row=target_row, column=COL_ITEM_NAME).value = gift["name"]
sheet.cell(row=target_row, column=COL_QTY).value = gift["qty"]
sheet.cell(row=target_row, column=COL_ORDER_LIST).value = " / ".join(entry["matched"] + [GIFT_LABEL])
for col in range(1, COL_LAST + 1):
sheet.cell(row=target_row, column=col).fill = YELLOW_FILL
def _prune_to_rows(sheet, header_row, keep_rows, last_row):
"""헤더와 keep_rows만 남기고 나머지 데이터 행을 지운다."""
keep = set(keep_rows)
to_delete = [row for row in range(header_row + 1, last_row + 1) if row not in keep]
if not to_delete:
return
groups = []
for row in to_delete:
if groups and groups[-1][0] + groups[-1][1] == row:
groups[-1][1] += 1
else:
groups.append([row, 1])
for start, count in reversed(groups):
sheet.delete_rows(start, count)
def _parse_selected(selected_json, empty_detail="상품을 최소 1개 선택해주세요."):
try:
parsed = json.loads(selected_json or "[]")
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="선택한 목록 값을 읽지 못했습니다.")
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail="선택한 목록 형식이 올바르지 않습니다.")
names = [str(v).strip() for v in parsed if str(v).strip()]
if not names:
raise HTTPException(status_code=400, detail=empty_detail)
return names
def _validate_gift(gift_item_code, gift_name, gift_qty, limit):
if limit < 1:
raise HTTPException(status_code=400, detail="선착순 인원은 1명 이상이어야 합니다.")
if gift_qty < 1:
raise HTTPException(status_code=400, detail="사은품 수량은 1개 이상이어야 합니다.")
code = (gift_item_code or "").strip()
if not code:
raise HTTPException(status_code=400, detail="사은품(세트 코드)을 선택해주세요.")
return {"item_code": code, "name": (gift_name or "").strip(), "qty": gift_qty}
@router.post("/first-come/analyze")
async def first_come_analyze(file: UploadFile = File(...)):
"""업로드된 발주서에서 주문목록(Q열) 후보를 뽑아 가나다순으로 돌려준다."""
file_bytes = await file.read()
scan = scan_order_sheet(file_bytes, file.filename)
return {
"status": "success",
"file_name": file.filename,
"sheet_name": scan["sheet_name"],
"header_row": scan["header_row"],
"total_rows": scan["last_row"] - scan["header_row"],
"total_orders": len(scan["sequence"]),
"order_list_items": scan["items"],
}
@router.post("/first-come/preview")
async def first_come_preview(
file: UploadFile = File(...),
selected_json: str = Form(...),
limit: int = Form(...),
gift_item_code: str = Form(...),
gift_name: str = Form(""),
gift_qty: int = Form(1),
):
"""다운로드 전에 어떤 주문에 사은품이 붙는지 미리 보여준다. (파일을 다시 쓰지 않는다)"""
selected_names = _parse_selected(selected_json, "사은품이 지급되는 주문을 최소 1개 선택해주세요.")
_validate_gift(gift_item_code, gift_name, gift_qty, limit)
file_bytes = await file.read()
scan = scan_order_sheet(file_bytes, file.filename)
result = _first_come_plan(scan, selected_names, limit)
added = [
{
"order_no": entry["order_no"],
"order_date": entry["order_date"],
"source_row": entry["source_row"],
"new_row": entry["new_row"],
"seq": entry["seq"],
"matched": entry["matched"],
}
for entry in result["plan"]
]
return {
"status": "success",
"matched_order_count": result["matched_order_count"],
"applied_count": len(result["plan"]),
"added": added,
"warnings": _first_come_warnings(result, limit),
}
@router.post("/first-come/download")
async def first_come_download(
file: UploadFile = File(...),
selected_json: str = Form(...),
limit: int = Form(...),
gift_item_code: str = Form(...),
gift_name: str = Form(""),
gift_qty: int = Form(1),
mode: str = Form("full"),
):
"""가공된 발주 파일을 내려준다.
mode=full : 원본 전체 + 각 주문 아래에 끼워 넣은 사은품 행
mode=gift : 사은품이 적용된 주문의 모든 행 + 사은품 행
"""
selected_names = _parse_selected(selected_json, "사은품이 지급되는 주문을 최소 1개 선택해주세요.")
gift = _validate_gift(gift_item_code, gift_name, gift_qty, limit)
file_bytes = await file.read()
scan = scan_order_sheet(file_bytes, file.filename)
result = _first_come_plan(scan, selected_names, limit)
if not result["plan"]:
raise HTTPException(
status_code=400,
detail="조건에 맞는 주문이 없어 사은품 행을 추가하지 못했습니다. 선택 항목을 확인해주세요.",
)
try:
workbook = load_workbook(io.BytesIO(file_bytes))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"엑셀 파일을 열 수 없습니다: {exc}")
sheet = workbook.active
_first_come_write(sheet, result, gift)
if mode == "gift":
_prune_to_rows(sheet, scan["header_row"], result["gift_order_rows"], result["final_last_row"])
filename = _output_name(file.filename, "선착순사은품_대상주문")
else:
# 전체 발주 파일은 업로드한 파일 이름 그대로 내려준다
filename = _same_name(file.filename)
return excel_response(workbook, filename)
# =====================================================================
# 기능 2) 행사 주문 추출
# 주문 CSV를 올려 K열(주문상품명)로 상품을 고른 뒤,
# - product 모드: 그 상품이 담긴 행만
# - order 모드 : 그 상품을 산 주문번호(B열)의 모든 행
# 만 남긴 엑셀을 내려준다. N열에는 =L×M 수식을 넣고 노란색을 칠한다.
# =====================================================================
_CSV_ENCODINGS = ("utf-8-sig", "cp949", "utf-8")
def _decode_csv(file_bytes):
"""카페24 CSV는 UTF-8(BOM), CP949(EUC-KR), UTF-16 중 하나로 내려온다.
UTF-16 파일을 CP949로 잘못 읽으면 글자 사이에 NUL이 끼어 csv 파서가 죽으므로
BOM과 NUL 비율로 UTF-16을 먼저 걸러낸다.
"""
if file_bytes[:2] in (b"\xff\xfe", b"\xfe\xff"):
try:
return file_bytes.decode("utf-16"), "utf-16"
except UnicodeDecodeError:
pass
# BOM이 없는 UTF-16은 NUL 바이트가 많다는 특징으로 판별한다
head = file_bytes[:4096]
if head and head.count(0) > len(head) * 0.2:
for encoding in ("utf-16-le", "utf-16-be"):
try:
return file_bytes.decode(encoding), encoding
except UnicodeDecodeError:
continue
for encoding in _CSV_ENCODINGS:
try:
return file_bytes.decode(encoding), encoding
except UnicodeDecodeError:
continue
return file_bytes.decode("utf-8", errors="replace"), "utf-8(일부 깨짐)"
def _normalize_csv_text(text):
"""줄바꿈을 \\n으로 통일하고 NUL을 제거한다.
CR(\\r)만 쓰는 파일이나 UTF-16 잔재가 남으면 csv 파서가
'new-line character seen in unquoted field' 오류로 죽는다.
"""
if "\x00" in text:
text = text.replace("\x00", "")
if "\r" in text:
text = text.replace("\r\n", "\n").replace("\r", "\n")
return text.lstrip("")
def _sniff_delimiter(sample_line):
"""구분자 추정. 카페24는 쉼표지만 탭으로 받는 경우도 있어 대비한다."""
counts = {d: sample_line.count(d) for d in (",", "\t", ";", "|")}
best = max(counts, key=counts.get)
return best if counts[best] > 0 else ","
def _cell_at(row, column):
"""1-based 열 번호로 CSV 행에서 값을 꺼낸다. 없으면 빈 문자열."""
return row[column - 1].strip() if len(row) >= column else ""
def event_extract_product_name(product_value):
"""주문상품명에서 첫 번째 ' -' 앞부분만 잘라낸다."""
text = (product_value or "").strip()
if not text:
return ""
return text.split(EE_SPLIT_TOKEN)[0].strip()
def _to_number(value):
"""수식이 계산되도록 L·M 값을 숫자로 바꾼다. 숫자가 아니면 원본 그대로."""
text = (value or "").strip().replace(",", "")
if not text:
return value
try:
return int(text)
except ValueError:
pass
try:
return float(text)
except ValueError:
return value
def scan_event_csv(file_bytes, filename):
"""주문 CSV를 읽어 상품명 후보와 행/주문 정보를 뽑는다. 같은 파일은 캐시 사용.
예상 못한 오류도 화면에 이유가 보이도록 400으로 감싸서 돌려준다.
(그냥 두면 500이 되어 사용자에게는 원인 없는 실패로만 보인다)
"""
cache_key = "event-extract:" + hashlib.sha1(file_bytes).hexdigest()
cached = _cache_get(cache_key)
if cached is not None:
return cached
name = (filename or "").lower()
if not name.endswith(".csv"):
raise HTTPException(status_code=400, detail="CSV 파일(.csv)만 업로드할 수 있습니다.")
try:
return _scan_event_csv_uncached(file_bytes, cache_key)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=400,
detail=f"CSV를 분석하지 못했습니다 ({type(exc).__name__}: {exc}). "
"파일이 손상되었거나 예상과 다른 형식일 수 있습니다.",
)
def _scan_event_csv_uncached(file_bytes, cache_key):
text, encoding = _decode_csv(file_bytes)
text = _normalize_csv_text(text)
if not text.strip():
raise HTTPException(status_code=400, detail="내용이 비어 있는 파일입니다.")
lines = text.splitlines()
first_line = lines[0] if lines else ""
delimiter = _sniff_delimiter(first_line)
try:
all_rows = list(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter))
except csv.Error as exc:
raise HTTPException(status_code=400, detail=f"CSV 형식을 읽지 못했습니다: {exc}")
if not all_rows:
raise HTTPException(status_code=400, detail="CSV에서 읽어낼 행이 없습니다.")
# 헤더 행: K열에 '주문상품명'이 들어간 줄. 못 찾으면 첫 줄을 헤더로 본다.
header_index = 0
for index, row in enumerate(all_rows[:_HEADER_SCAN_ROWS]):
if "주문상품명" in _cell_at(row, EE_COL_PRODUCT):
header_index = index
break
header = all_rows[header_index]
data_rows = [row for row in all_rows[header_index + 1:] if any(c.strip() for c in row)]
if not data_rows:
raise HTTPException(status_code=400, detail="헤더 아래에 주문 데이터가 없습니다.")
stats = {}
meta = []
orders_of_product = {}
for row in data_rows:
order_no = _cell_at(row, EE_COL_ORDER_NO)
product = event_extract_product_name(_cell_at(row, EE_COL_PRODUCT))
meta.append({"order_no": order_no, "product": product})
if not product:
continue
entry = stats.setdefault(product, {"name": product, "row_count": 0, "orders": set()})
entry["row_count"] += 1
if order_no:
entry["orders"].add(order_no)
orders_of_product.setdefault(product, set())
if order_no:
orders_of_product[product].add(order_no)
items = [
{"name": e["name"], "row_count": e["row_count"], "order_count": len(e["orders"])}
for e in sorted(stats.values(), key=lambda x: x["name"])
]
column_count = max([len(header)] + [len(r) for r in data_rows])
scan = {
"encoding": encoding,
"delimiter": delimiter,
"header_row": header_index + 1,
"header": header,
"rows": data_rows,
"meta": meta,
"items": items,
"orders_of_product": orders_of_product,
"column_count": column_count,
}
_cache_put(cache_key, scan)
return scan
def _event_extract_select(scan, selected_names, mode):
"""조건에 맞는 행 번호(데이터 행 기준 0-based)를 고른다."""
selected = set(selected_names)
matched_rows = [i for i, m in enumerate(scan["meta"]) if m["product"] in selected]
if mode == "order":
# 해당 상품을 산 주문번호(B열)와 같은 주문번호를 가진 모든 행을 남긴다
target_orders = {scan["meta"][i]["order_no"] for i in matched_rows if scan["meta"][i]["order_no"]}
keep = [i for i, m in enumerate(scan["meta"]) if m["order_no"] and m["order_no"] in target_orders]
else:
keep = matched_rows
order_numbers = {scan["meta"][i]["order_no"] for i in keep if scan["meta"][i]["order_no"]}
return {
"keep": keep,
"matched_row_count": len(matched_rows),
"row_count": len(keep),
"order_count": len(order_numbers),
}
def _is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _pixels_to_column_width(pixels):
"""픽셀을 엑셀 열 너비 단위로 환산.
엑셀의 열 너비는 픽셀이 아니라 '기본 글꼴의 숫자 한 글자 폭' 단위라서
글꼴에 따라 배율이 달라진다. 이 프로그램이 쓰는 환경 기준으로
145px = 너비 17.5 이므로 1글자 = 8px, 좌우 여백 5px로 계산한다.
"""
return round(max(pixels - EE_WIDTH_PADDING_PIXELS, 0) / EE_PIXELS_PER_WIDTH_UNIT, 3)
def _rename_header(value):
"""장황한 카페24 제목을 짧은 제목으로 바꾼다. 목록에 없으면 원본 유지."""
text = (value or "").strip()
if not text:
return value
if text in EE_HEADER_RENAMES:
return EE_HEADER_RENAMES[text]
return _EE_HEADER_RENAMES_LOOSE.get(re.sub(r"\s+", "", text), text)
def _display_width(value):
"""열 너비 자동 맞춤용 글자 폭 추정. 한글은 영문보다 넓게 잡는다."""
if value is None or value == "":
return 0
if _is_number(value):
text = f"{value:,.0f}" if float(value).is_integer() else f"{value:,.2f}"
else:
text = str(value)
return sum(1.8 if ord(ch) > 127 else 1.1 for ch in text)
def _event_extract_build_workbook(scan, keep_indexes):
"""고른 행만 담아 서식까지 적용한 워크북을 만든다.
정렬 : D열 기준 텍스트 오름차순 (같은 값끼리는 원래 순서 유지)
A~AB : 1행은 가운데 정렬·파랑 배경·흰 글자, 자동 필터, A2 틀고정
열 너비는 A~H·L~T는 픽셀 고정, 나머지는 내용에 맞춰 자동
L~S : 문자로 들어온 숫자를 숫자로 바꾸고 쉼표 서식, 값이 0이면 빈 셀
O~S : 같은 주문번호(B열) 안에서 첫 행과 같은 값이면 지운다
배경 : 주문번호가 바뀔 때마다 #B4C6E7 / #FFD966 을 번갈아 칠하고,
마지막에 N열(=L×M 수식이 들어간 셀)만 노란색으로 덮어쓴다
N : =L×M 수식(계산값 아님)
"""
workbook = Workbook()
sheet = workbook.active
sheet.title = "행사 주문 추출"
column_count = max(scan["column_count"], EE_COL_N)
format_last = min(column_count, EE_FORMAT_LAST_COL)
widths = {}
def note_width(col, value):
width = _display_width(value)
if width > widths.get(col, 0):
widths[col] = width
# D열 텍스트 오름차순. 파이썬 정렬은 안정적이라 같은 값끼리는 원래 순서가 유지된다.
keep_indexes = sorted(
keep_indexes,
key=lambda index: _cell_at(scan["rows"][index], EE_SORT_COL),
)
# ---- 제목행 ----
for col in range(1, column_count + 1):
value = _rename_header(_cell_at(scan["header"], col)) or None
cell = sheet.cell(row=1, column=col, value=value)
if col <= format_last:
cell.fill = EE_HEADER_FILL
cell.font = EE_HEADER_FONT
cell.alignment = EE_HEADER_ALIGN
note_width(col, value)
# ---- 데이터 행 ----
first_seen = {} # (주문번호, 열) -> 그 주문에서 처음 만난 값
row_orders = [] # (엑셀 행번호, 주문번호) - 아래 배경색 칠하기에 쓴다
for offset, data_index in enumerate(keep_indexes):
excel_row = offset + 2
source = scan["rows"][data_index]
order_no = _cell_at(source, EE_COL_ORDER_NO)
row_orders.append((excel_row, order_no))
for col in range(1, column_count + 1):
if col == EE_COL_N:
continue # 아래에서 수식으로 채운다
raw = _cell_at(source, col)
# O~S: 같은 주문번호에서 이미 나온 값과 같으면 빈 셀로 둔다
if order_no and EE_DEDUPE_FIRST_COL <= col <= EE_DEDUPE_LAST_COL:
key = (order_no, col)
if key in first_seen:
if first_seen[key] == raw:
continue
elif raw != "":
first_seen[key] = raw
# L~S: 숫자로 변환 + 쉼표 서식, 0이면 빈 셀
if EE_NUM_FIRST_COL <= col <= EE_NUM_LAST_COL:
value = _to_number(raw)
if _is_number(value):
if value == 0:
continue
cell = sheet.cell(row=excel_row, column=col, value=value)
cell.number_format = (
EE_COMMA_FORMAT if float(value).is_integer() else EE_COMMA_FORMAT_DECIMAL
)
note_width(col, value)
continue
if raw != "":
sheet.cell(row=excel_row, column=col, value=raw)
note_width(col, raw)
# 계산된 값이 아니라 수식 자체를 그대로 넣는다 (배경색은 아래에서 한 번에)
cell = sheet.cell(row=excel_row, column=EE_COL_N)
cell.value = f"=L{excel_row}*M{excel_row}"
cell.number_format = EE_COMMA_FORMAT
# 수식 결과는 저장 시점에 알 수 없으므로 L×M을 미리 계산해 너비만 가늠한다
left = sheet.cell(row=excel_row, column=EE_COL_L).value
right = sheet.cell(row=excel_row, column=EE_COL_M).value
if _is_number(left) and _is_number(right):
note_width(EE_COL_N, left * right)
# ---- 주문번호 묶음별 배경색 (두 색을 번갈아) ----
previous_order = None
color_index = 0
for excel_row, order_no in row_orders:
if previous_order is not None and order_no != previous_order:
color_index = 1 - color_index # 주문번호가 바뀌면 다른 색으로
previous_order = order_no
fill = EE_GROUP_FILLS[color_index]
for col in range(1, format_last + 1):
sheet.cell(row=excel_row, column=col).fill = fill
# ---- 마지막으로 N열(수식이 들어간 셀)만 노란색으로 덮어쓴다 ----
for excel_row, _order_no in row_orders:
cell = sheet.cell(row=excel_row, column=EE_COL_N)
if cell.value not in (None, ""):
cell.fill = YELLOW_FILL
# ---- 열 너비: 지정한 열은 픽셀 고정, 나머지는 자동 ----
for col in range(1, format_last + 1):
pixels = EE_MANUAL_COL_PIXELS.get(col)
if pixels is not None:
width = _pixels_to_column_width(pixels)
else:
width = min(max(widths.get(col, 0) + 2, EE_MIN_COL_WIDTH), EE_MAX_COL_WIDTH)
sheet.column_dimensions[get_column_letter(col)].width = width
# ---- 자동 필터 + A2 틀고정 ----
last_letter = get_column_letter(format_last)
sheet.auto_filter.ref = f"A1:{last_letter}{sheet.max_row}"
if sheet.max_row > 1:
# 행은 이미 D열 오름차순으로 써 두었고, 필터에도 정렬 기준을 남겨 둔다
sort_letter = get_column_letter(EE_SORT_COL)
sheet.auto_filter.add_sort_condition(f"{sort_letter}2:{sort_letter}{sheet.max_row}")
sheet.freeze_panes = "A2"
return workbook
def _parse_extract_mode(mode):
if mode not in ("product", "order"):
raise HTTPException(status_code=400, detail="추출 조건이 올바르지 않습니다.")
return mode
@router.post("/event-extract/analyze")
async def event_extract_analyze(file: UploadFile = File(...)):
"""업로드된 주문 CSV에서 상품명 후보를 뽑아 가나다순으로 돌려준다."""
file_bytes = await file.read()
scan = scan_event_csv(file_bytes, file.filename)
warnings = []
if scan["column_count"] < EE_COL_N:
warnings.append(
f"CSV의 열이 {scan['column_count']}개뿐이라 L·M열 값이 비어 있을 수 있습니다. "
"N열 수식은 그대로 넣지만 결과가 0이 될 수 있습니다."
)
return {
"status": "success",
"file_name": file.filename,
"encoding": scan["encoding"],
"header_row": scan["header_row"],
"total_rows": len(scan["rows"]),
"total_orders": len({m["order_no"] for m in scan["meta"] if m["order_no"]}),
"column_count": scan["column_count"],
"product_items": scan["items"],
"warnings": warnings,
}
@router.post("/event-extract/download")
async def event_extract_download(
file: UploadFile = File(...),
selected_json: str = Form(...),
mode: str = Form("product"),
):
"""조건에 맞는 행만 남기고 N열에 =L×M 수식을 넣은 엑셀을 내려준다."""
selected_names = _parse_selected(selected_json)
_parse_extract_mode(mode)
file_bytes = await file.read()
scan = scan_event_csv(file_bytes, file.filename)
result = _event_extract_select(scan, selected_names, mode)
if not result["keep"]:
raise HTTPException(
status_code=400,
detail="조건에 맞는 주문이 없어 추출할 행이 없습니다. 선택 항목을 확인해주세요.",
)
workbook = _event_extract_build_workbook(scan, result["keep"])
suffix = "해당상품만" if mode == "product" else "해당주문"
# 화면에 추출 결과를 알려주기 위한 값. 파일과 함께 헤더로 실어 보낸다.
summary = {
"X-Extract-Matched-Rows": str(result["matched_row_count"]),
"X-Extract-Rows": str(result["row_count"]),
"X-Extract-Orders": str(result["order_count"]),
"X-Extract-Total-Rows": str(len(scan["rows"])),
}
return excel_response(workbook, _output_name(file.filename, f"행사추출_{suffix}"), summary)