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>
This commit is contained in:
2026-08-31 23:56:17 +09:00
parent a2a5d53d9b
commit 065e81bf97
11 changed files with 9830 additions and 1392 deletions
+85 -31
View File
@@ -2,6 +2,7 @@ import requests
import json import json
import os import os
import time import time
from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
# 환경변수에서 카페24 인증 정보 로드 # 환경변수에서 카페24 인증 정보 로드
@@ -9,14 +10,21 @@ CLIENT_ID = os.getenv("CAFE24_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET", "") CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET", "")
MALL_ID = os.getenv("CAFE24_MALL_ID", "miraskitchen") MALL_ID = os.getenv("CAFE24_MALL_ID", "miraskitchen")
REDIRECT_URI = os.getenv("CAFE24_REDIRECT_URI", "") REDIRECT_URI = os.getenv("CAFE24_REDIRECT_URI", "")
TOKEN_FILE = "cafe24_tokens.json" TOKEN_FILE = os.getenv("CAFE24_TOKEN_FILE", str(Path(__file__).resolve().parent / "cafe24_tokens.json"))
REQUEST_TIMEOUT = 30
def get_auth_url(): def get_auth_url(redirect_uri=None, state=None):
# state parameter could be added for security but keeping it simple for local app redirect_uri = redirect_uri or REDIRECT_URI
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/authorize" url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/authorize"
url += f"?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}" params = {
url += f"&scope=mall.read_order,mall.write_order,mall.read_product" "response_type": "code",
return url "client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": "mall.read_order mall.write_order mall.read_product",
}
if state:
params["state"] = state
return requests.Request("GET", url, params=params).prepare().url
import base64 import base64
@@ -25,7 +33,8 @@ def _get_basic_auth_header():
encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8') encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
return f"Basic {encoded}" return f"Basic {encoded}"
def request_new_token(auth_code: str): def request_new_token(auth_code: str, redirect_uri=None):
redirect_uri = redirect_uri or REDIRECT_URI
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token" url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token"
headers = { headers = {
"Authorization": _get_basic_auth_header(), "Authorization": _get_basic_auth_header(),
@@ -34,15 +43,15 @@ def request_new_token(auth_code: str):
data = { data = {
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": auth_code, "code": auth_code,
"redirect_uri": REDIRECT_URI "redirect_uri": redirect_uri
} }
response = requests.post(url, headers=headers, data=data) response = requests.post(url, headers=headers, data=data, timeout=REQUEST_TIMEOUT)
if response.status_code == 200: if response.status_code == 200:
_save_tokens(response.json()) _save_tokens(response.json())
return True return True
else: else:
raise Exception(f"Failed to get token: {response.text}") raise Exception(f"카페24 인증 토큰 발급에 실패했습니다. (HTTP {response.status_code})")
def refresh_access_token(refresh_token: str): def refresh_access_token(refresh_token: str):
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token" url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token"
@@ -55,15 +64,13 @@ def refresh_access_token(refresh_token: str):
"refresh_token": refresh_token "refresh_token": refresh_token
} }
response = requests.post(url, headers=headers, data=data) response = requests.post(url, headers=headers, data=data, timeout=REQUEST_TIMEOUT)
if response.status_code == 200: if response.status_code == 200:
_save_tokens(response.json()) _save_tokens(response.json())
return True return True
else: else:
# Refresh token might be expired. Need to re-authenticate. # Preserve the token file so a failed refresh never destroys diagnostics.
if os.path.exists(TOKEN_FILE): raise Exception("카페24 인증이 만료되었습니다. 화면의 '재연동 필요' 버튼을 눌러 다시 연동해주세요.")
os.remove(TOKEN_FILE)
raise Exception("Refresh token expired or invalid. Please re-authenticate.")
def _save_tokens(token_data): def _save_tokens(token_data):
# Cafe24 returns expires_at in string format like "2023-10-01T12:00:00.000" # Cafe24 returns expires_at in string format like "2023-10-01T12:00:00.000"
@@ -76,6 +83,62 @@ def _save_tokens(token_data):
with open(TOKEN_FILE, 'w', encoding='utf-8') as f: with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
json.dump(token_data, f) json.dump(token_data, f)
def _parse_cafe24_datetime(value):
if not value:
return None
text = str(value).strip()
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
pass
for date_format in ("%m/%d/%Y %H:%M:%S", "%Y/%m/%d %H:%M:%S"):
try:
return datetime.strptime(text, date_format)
except ValueError:
continue
return None
def _is_expired(value, *, leeway_seconds=0):
expires_at = _parse_cafe24_datetime(value)
if expires_at is None:
return True
now = datetime.now(expires_at.tzinfo) if expires_at.tzinfo else datetime.now()
return now >= expires_at - timedelta(seconds=leeway_seconds)
def get_token_status():
if not os.path.exists(TOKEN_FILE):
return {
"authenticated": False,
"reauth_required": True,
"message": "카페24 연동이 필요합니다.",
}
try:
with open(TOKEN_FILE, "r", encoding="utf-8") as f:
tokens = json.load(f)
except (OSError, json.JSONDecodeError):
return {
"authenticated": False,
"reauth_required": True,
"message": "카페24 인증 파일을 읽을 수 없습니다. 다시 연동해주세요.",
}
refresh_expired = _is_expired(tokens.get("refresh_token_expires_at"))
access_expired = _is_expired(tokens.get("expires_at"), leeway_seconds=60)
authenticated = bool(tokens.get("access_token")) and not refresh_expired
return {
"authenticated": authenticated,
"reauth_required": not authenticated,
"access_expired": access_expired,
"message": (
"카페24 인증이 만료되었습니다. 다시 연동해주세요."
if not authenticated
else "카페24가 연동되어 있습니다."
),
}
def get_valid_access_token(): def get_valid_access_token():
if not os.path.exists(TOKEN_FILE): if not os.path.exists(TOKEN_FILE):
raise Exception("No tokens found. Please authenticate first.") raise Exception("No tokens found. Please authenticate first.")
@@ -83,18 +146,9 @@ def get_valid_access_token():
with open(TOKEN_FILE, 'r', encoding='utf-8') as f: with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
tokens = json.load(f) tokens = json.load(f)
expires_at_str = tokens.get('expires_at') if _is_expired(tokens.get("expires_at"), leeway_seconds=60):
# Parse Cafe24 typical datetime format or isoformat if not tokens.get("refresh_token") or _is_expired(tokens.get("refresh_token_expires_at")):
try: raise Exception("카페24 인증이 만료되었습니다. 화면의 '재연동 필요' 버튼을 눌러 다시 연동해주세요.")
if '.' in expires_at_str: # e.g. "2023-10-01T12:00:00.000"
expires_at = datetime.strptime(expires_at_str[:19], "%Y-%m-%dT%H:%M:%S")
else:
expires_at = datetime.fromisoformat(expires_at_str)
except:
expires_at = datetime.now() - timedelta(minutes=1) # force refresh on parse error
# Check if expired
if datetime.now() >= expires_at:
print("Cafe24 Access token expired, refreshing...") print("Cafe24 Access token expired, refreshing...")
refresh_access_token(tokens.get('refresh_token')) refresh_access_token(tokens.get('refresh_token'))
return get_valid_access_token() return get_valid_access_token()
@@ -119,7 +173,7 @@ def get_cafe24_orders_count(status="N20"):
"order_status": status "order_status": status
} }
response = requests.get(url, headers=headers, params=params) response = requests.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT)
if response.status_code == 200: if response.status_code == 200:
return response.json().get('count', 0) return response.json().get('count', 0)
return 0 return 0
@@ -155,7 +209,7 @@ def get_cafe24_orders(status="N20", progress_callback=None):
"embed": "receivers,items" # embed items and receiver addresses "embed": "receivers,items" # embed items and receiver addresses
} }
response = requests.get(url, headers=headers, params=params) response = requests.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT)
if response.status_code != 200: if response.status_code != 200:
raise Exception(f"Failed to fetch Cafe24 orders: {response.text}") raise Exception(f"Failed to fetch Cafe24 orders: {response.text}")
@@ -205,7 +259,7 @@ def update_cafe24_tracking(dispatch_list):
} }
} }
response = requests.put(url, headers=headers, json=payload) response = requests.put(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
if response.status_code in [200, 201]: if response.status_code in [200, 201]:
results["success"].append(order_id) results["success"].append(order_id)
@@ -236,7 +290,7 @@ def get_product_details(product_no):
} }
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/products/{product_no}?embed=variants" url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/products/{product_no}?embed=variants"
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
if response.status_code == 200: if response.status_code == 200:
return response.json().get('product', {}) return response.json().get('product', {})
return {} return {}
+345 -202
View File
File diff suppressed because it is too large Load Diff
+701
View File
@@ -0,0 +1,701 @@
# -*- coding: utf-8 -*-
"""
쿠팡 밀크런 붙여넣기 분석기.
쿠팡 밀크런 발주서(제품코드 / 제품명 / 수량, 탭 구분)를 붙여넣으면
코드표 DB(itemcode_db)의 세트-단품 구성을 참조해 낱개 코드별 합계 수량을 계산합니다.
제품코드 규칙: "MT-7000_2" 형태에서 마지막 "_숫자"는 앞 코드를 해당 숫자만큼
곱하라는 뜻입니다 (예: MT-7000_2 108개 = MT-7000 216개로 계산).
"""
import io
import json
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
import pandas as pd
import psycopg2
import psycopg2.extras
import requests
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
router = APIRouter(prefix="/api/coupang-milkrun", tags=["Coupang Milkrun"])
# 환경변수 기반 DB 설정
DB_CONFIG = {
'host': os.getenv('POSTGRES_HOST', 'postgres-db'),
'port': int(os.getenv('POSTGRES_PORT', '5432')),
'dbname': os.getenv('ITEMCODE_DB', 'itemcode_db'),
'user': os.getenv('POSTGRES_USER', 'king'),
'password': os.getenv('POSTGRES_PASSWORD', ''),
}
def get_db_conn():
return psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
# 분석 요청마다 SSH 터널로 새 DB 커넥션을 맺으면 매번 수백ms가 소요되므로,
# 코드표(단품/세트 구성)는 잘 바뀌지 않는 참조 데이터로 보고 짧게 캐싱한다.
_CODE_MAP_CACHE_TTL_SECONDS = 120
_code_map_cache = {"loaded_at": 0.0, "single_map": {}, "set_components": {}}
def _get_code_maps():
now = time.monotonic()
if now - _code_map_cache["loaded_at"] < _CODE_MAP_CACHE_TTL_SECONDS:
return _code_map_cache["single_map"], _code_map_cache["set_components"]
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute("SELECT item_code, name, sabangnet_code FROM single_items")
single_map = {r["item_code"]: dict(r) for r in cursor.fetchall()}
cursor.execute("SELECT set_code, single_code, quantity FROM set_components")
set_components = {}
for r in cursor.fetchall():
set_components.setdefault(r["set_code"], []).append(
(r["single_code"], r["quantity"])
)
finally:
conn.close()
_code_map_cache["single_map"] = single_map
_code_map_cache["set_components"] = set_components
_code_map_cache["loaded_at"] = now
return single_map, set_components
def invalidate_code_map_cache():
"""코드표(단품/세트)가 수정되면 즉시 반영되도록 캐시를 비운다."""
_code_map_cache["loaded_at"] = 0.0
class MilkrunAnalyzeRequest(BaseModel):
text: str
_MULTIPLIER_SUFFIX_RE = re.compile(r'^(?P<base>.+)_(?P<mult>\d+)$')
_HEADER_LABELS = {"제품코드", "상품코드", "아이템코드", "item_code", "code"}
def _parse_pasted_rows(text: str):
"""탭(또는 2칸 이상 공백)으로 구분된 '코드/이름/수량' 3열을 줄 단위로 파싱."""
rows = []
for raw_line in text.splitlines():
line = raw_line.strip('\r\n')
if not line.strip():
continue
parts = [p.strip() for p in line.split('\t')]
while parts and parts[-1] == '':
parts.pop()
if len(parts) < 3:
parts = [p for p in re.split(r'\s{2,}', line.strip()) if p != '']
if len(parts) < 3:
continue
code_raw, name, qty_raw = parts[0], parts[1], parts[-1]
if not code_raw or code_raw in _HEADER_LABELS:
continue
qty_clean = qty_raw.replace(',', '').strip()
if not qty_clean.isdigit():
continue
qty = int(qty_clean)
if qty <= 0:
continue
rows.append({"code_raw": code_raw, "name": name, "qty": qty})
return rows
def _resolve_code(code_raw, single_map, set_components):
"""코드 원문을 (기준코드, 배수, 종류) 로 해석.
1순위: 코드 원문이 DB에 그대로 존재하면 배수 없이 사용 (코드 자체에 '_'
포함된 실제 등록 코드를 오탐하지 않기 위함).
2순위: 끝의 '_숫자'를 배수로 떼어낸 기준코드가 DB에 존재하면 그것을 사용.
3순위: 둘 다 없으면 미등록으로 처리하되, 배수 해석 결과는 화면 표시용으로 보존.
"""
if code_raw in set_components:
return code_raw, 1, "set"
if code_raw in single_map:
return code_raw, 1, "single"
m = _MULTIPLIER_SUFFIX_RE.match(code_raw)
if m:
base = m.group('base')
mult = int(m.group('mult'))
if base in set_components:
return base, mult, "set"
if base in single_map:
return base, mult, "single"
return base, mult, None
return code_raw, 1, None
def _compute_milkrun_result(text: str):
rows = _parse_pasted_rows(text)
if not rows:
return {"lines": [], "totals": [], "unresolved": []}
single_map, set_components = _get_code_maps()
totals = {}
unresolved = {}
line_results = []
for row in rows:
base_code, multiplier, kind = _resolve_code(row["code_raw"], single_map, set_components)
effective_qty = row["qty"] * multiplier
if kind == "set":
breakdown = []
for single_code, per_set_qty in set_components[base_code]:
add_qty = effective_qty * per_set_qty
totals[single_code] = totals.get(single_code, 0) + add_qty
breakdown.append({
"single_code": single_code,
"per_unit_qty": per_set_qty,
"qty": add_qty,
})
line_results.append({
**row,
"base_code": base_code,
"multiplier": multiplier,
"effective_qty": effective_qty,
"resolved": True,
"type": "set",
"breakdown": breakdown,
})
elif kind == "single":
totals[base_code] = totals.get(base_code, 0) + effective_qty
line_results.append({
**row,
"base_code": base_code,
"multiplier": multiplier,
"effective_qty": effective_qty,
"resolved": True,
"type": "single",
"breakdown": [{"single_code": base_code, "qty": effective_qty}],
})
else:
unresolved[base_code] = unresolved.get(base_code, 0) + effective_qty
line_results.append({
**row,
"base_code": base_code,
"multiplier": multiplier,
"effective_qty": effective_qty,
"resolved": False,
"type": "unknown",
"breakdown": [],
})
totals_list = [
{
"single_code": code,
"name": single_map.get(code, {}).get("name", ""),
"sabangnet_code": single_map.get(code, {}).get("sabangnet_code", ""),
"total_qty": qty,
}
for code, qty in totals.items()
]
totals_list.sort(key=lambda x: x["single_code"])
unresolved_list = [
{"code": code, "qty": qty} for code, qty in sorted(unresolved.items())
]
return {
"lines": line_results,
"totals": totals_list,
"unresolved": unresolved_list,
}
@router.post("/analyze")
async def analyze_milkrun(payload: MilkrunAnalyzeRequest):
result = _compute_milkrun_result(payload.text)
return {"status": "success", **result}
@router.post("/download")
async def download_milkrun(payload: MilkrunAnalyzeRequest):
result = _compute_milkrun_result(payload.text)
df = pd.DataFrame([
{
"상품코드[필수]": t["sabangnet_code"],
"가용수량": t["total_qty"],
"불용수량": t["name"],
"바코드": "",
}
for t in result["totals"]
], columns=["상품코드[필수]", "가용수량", "불용수량", "바코드"])
output = io.BytesIO()
with pd.ExcelWriter(output, engine="openpyxl") as writer:
df.to_excel(writer, index=False, sheet_name="Sheet1")
worksheet = writer.sheets["Sheet1"]
from openpyxl.utils import get_column_letter
from openpyxl.styles import Alignment, PatternFill, Font
header_fill = PatternFill(start_color="833C0C", end_color="833C0C", fill_type="solid")
header_font = Font(name="나눔고딕", bold=True, color="FFFFFF")
data_font = Font(name="나눔고딕")
center_alignment = Alignment(horizontal="center", vertical="center")
for row in worksheet.iter_rows(min_row=1, max_row=worksheet.max_row, min_col=1, max_col=4):
for cell in row:
if cell.row == 1:
cell.fill = header_fill
cell.font = header_font
cell.alignment = center_alignment
else:
cell.font = data_font
for idx, col in enumerate(df.columns):
col_letter = get_column_letter(idx + 1)
max_length = 0
for cell in worksheet[col_letter]:
if cell.value is not None:
val_str = str(cell.value)
length = sum(1.8 if ord(c) > 127 else 1.1 for c in val_str)
if length > max_length:
max_length = length
worksheet.column_dimensions[col_letter].width = min(max_length + 2, 60)
output.seek(0)
filename = "쿠팡_밀크런_낱개코드.xlsx"
encoded_filename = urllib.parse.quote(filename)
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers=headers,
)
# =====================================================================
# 구글 드라이브 시트 불러오기
#
# 대상 파일은 두 가지 형태일 수 있어 둘 다 지원한다.
# 1) 네이티브 구글 시트 → Sheets API(gspread)로 탭 목록을 읽는다
# 2) Drive에 올라간 .xlsx → Drive API로 내려받아 openpyxl로 시트명을 읽는다
# (이 경우 구글 클라우드 프로젝트에서 Drive API가 켜져 있어야 한다)
# =====================================================================
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GSPREAD_CRED_FILE = os.getenv("GSPREAD_CRED_FILE", os.path.join(BASE_DIR, "manual-ordering.json"))
GSPREAD_CRED_JSON = os.getenv("GSPREAD_CRED_JSON", "")
SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets"
DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.readonly"
# 같은 파일을 연달아 부르므로 내려받은 통합문서와 시트 목록을 잠시 들고 있는다.
# 구글로 나가는 요청은 왕복이 길어서(수 초~수십 초) 캐시 효과가 크다.
_DRIVE_CACHE_TTL_SECONDS = 300
_drive_cache = {} # file_id -> (저장시각, 파일 bytes)
_sheet_tabs_cache = {} # file_id -> (저장시각, 시트 목록 응답)
# 구글이 응답을 안 주면 무한정 매달리지 않도록 (연결 대기, 응답 대기) 초
_HTTP_TIMEOUT = (10, 120)
_HTTP_READ_TIMEOUT = 120
# 밀크런 출고리스트 구글 시트 (화면에서 주소를 입력하지 않아도 되도록 기본값으로 둔다)
MILKRUN_SHEET_URL = os.getenv(
"MILKRUN_SHEET_URL",
"https://docs.google.com/spreadsheets/d/1J74op7lBZOgE27p4R28I3RWsv0EtXdii/edit",
)
_FILE_ID_RE = re.compile(r"/spreadsheets/d/([a-zA-Z0-9_-]+)|/file/d/([a-zA-Z0-9_-]+)|[?&]id=([a-zA-Z0-9_-]+)")
class SheetTabsRequest(BaseModel):
url: str = "" # 비우면 MILKRUN_SHEET_URL 을 쓴다
refresh: bool = False # true면 캐시를 무시하고 구글에서 다시 읽는다
class SheetRowsRequest(BaseModel):
url: str = ""
sheet: str
refresh: bool = False
# 밀크런 출고리스트에서 찾을 열 제목. 위치가 바뀌어도 제목으로 찾아낸다.
MILKRUN_COLUMN_LABELS = {"code": "제품코드", "name": "제품명", "qty": "수량"}
# 제목을 못 찾았을 때 쓸 기본 위치 (H=8 제품코드, I=9 제품명, J=10 수량)
MILKRUN_DEFAULT_COLUMNS = {"code": 8, "name": 9, "qty": 10}
MILKRUN_HEADER_SCAN_ROWS = 30
# 데이터가 끝났다고 판단할 연속 빈 행 수 (중간에 빈 줄이 있어도 넘어가도록 여유를 둔다)
MILKRUN_BLANK_RUN_LIMIT = 30
def extract_file_id(url_or_id):
"""구글 문서 주소에서 파일 ID를 뽑는다. ID를 그대로 넣어도 받는다."""
text = (url_or_id or "").strip()
if not text:
raise HTTPException(status_code=400, detail="구글 시트 주소를 입력해주세요.")
match = _FILE_ID_RE.search(text)
if match:
return next(group for group in match.groups() if group)
if "/" not in text and len(text) >= 20:
return text # 주소 대신 ID만 붙여넣은 경우
raise HTTPException(
status_code=400,
detail="주소에서 파일 ID를 찾지 못했습니다. "
"https://docs.google.com/spreadsheets/d/<파일ID>/edit 형태의 주소를 넣어주세요.",
)
def _service_account_info():
if GSPREAD_CRED_JSON:
try:
return json.loads(GSPREAD_CRED_JSON)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="GSPREAD_CRED_JSON 형식이 올바르지 않습니다.")
if os.path.exists(GSPREAD_CRED_FILE):
with open(GSPREAD_CRED_FILE, "r", encoding="utf-8") as fh:
return json.load(fh)
raise HTTPException(
status_code=500,
detail=f"구글 서비스 계정 키 파일을 찾을 수 없습니다: {GSPREAD_CRED_FILE}",
)
def _load_credentials(scopes):
try:
from google.oauth2.service_account import Credentials
except ImportError:
raise HTTPException(status_code=500, detail="google-auth 라이브러리가 설치되어 있지 않습니다.")
return Credentials.from_service_account_info(_service_account_info(), scopes=scopes)
def _service_account_email():
try:
return _service_account_info().get("client_email", "")
except HTTPException:
return ""
def _drive_error_detail(response):
"""Drive API 오류를 사용자가 무엇을 해야 할지 알 수 있는 문장으로 바꾼다."""
try:
message = response.json().get("error", {}).get("message", "")
except ValueError:
message = response.text[:300]
email = _service_account_email()
if response.status_code == 403 and "has not been used in project" in message:
return ("구글 드라이브 API가 꺼져 있고, 파일도 링크 공개 상태가 아니라 읽을 수 없습니다. "
"파일 공유 설정을 '링크가 있는 모든 사용자(뷰어)'로 바꾸거나, "
"구글 클라우드 콘솔에서 'Google Drive API'를 사용 설정해주세요. "
f"(원본 메시지: {message[:200]})")
if response.status_code in (403, 404):
return (f"파일에 접근할 수 없습니다. 구글 드라이브에서 이 파일을 '{email}' 계정에 공유(뷰어)하거나 "
f"'링크가 있는 모든 사용자'로 설정해주세요. (원본 메시지: {message[:200]})")
return f"구글 드라이브에서 파일을 가져오지 못했습니다 [{response.status_code}]: {message[:200]}"
def _drive_get(session, file_id, params):
return session.get(
f"https://www.googleapis.com/drive/v3/files/{file_id}",
params={**params, "supportsAllDrives": "true"},
timeout=60,
)
# 링크가 공개된 파일은 인증 없이 바로 받을 수 있다 (Drive API 불필요)
_PUBLIC_EXPORT_URLS = (
"https://docs.google.com/spreadsheets/d/{file_id}/export?format=xlsx",
"https://drive.google.com/uc?export=download&id={file_id}",
)
def _fetch_public_workbook(file_id):
"""링크 공개 파일을 인증 없이 내려받는다. 받지 못하면 None.
비공개 파일이면 구글이 로그인 HTML을 200으로 돌려주므로
xlsx(ZIP) 시그니처 'PK'를 확인해서 진짜 파일인지 가려낸다.
requests(urllib3) 대신 표준 라이브러리 urllib을 쓴다.
같은 파일을 받는 데 requests는 약 20초, urllib은 약 1.4초로 차이가 커서다.
(연결 수립 단계에서 지연이 생기며, 본문 전송 자체는 0.3초다)
"""
for template in _PUBLIC_EXPORT_URLS:
request = urllib.request.Request(
template.format(file_id=file_id),
headers={"User-Agent": "Mozilla/5.0 (compatible; CS-Integrated-Manager)"},
)
try:
with urllib.request.urlopen(request, timeout=_HTTP_READ_TIMEOUT) as response:
if response.status != 200:
continue
content = response.read()
except (urllib.error.URLError, OSError):
continue
if content[:2] == b"PK":
return content
return None
def _fetch_drive_workbook_authenticated(file_id):
try:
from google.auth.transport.requests import AuthorizedSession
except ImportError:
raise HTTPException(status_code=500, detail="google-auth 라이브러리가 설치되어 있지 않습니다.")
session = AuthorizedSession(_load_credentials([DRIVE_SCOPE]))
response = _drive_get(session, file_id, {"alt": "media"})
if response.status_code != 200:
raise HTTPException(status_code=400, detail=_drive_error_detail(response))
return response.content
def fetch_drive_workbook(file_id):
"""엑셀 파일을 내려받아 bytes로 돌려준다. (짧게 캐시)
1순위: 링크 공개 파일을 인증 없이 받기 (Drive API가 꺼져 있어도 동작)
2순위: 서비스 계정으로 Drive API 호출 (비공개 파일용)
"""
cached = _drive_cache.get(file_id)
if cached and time.monotonic() - cached[0] < _DRIVE_CACHE_TTL_SECONDS:
return cached[1]
content = _fetch_public_workbook(file_id)
if content is None:
content = _fetch_drive_workbook_authenticated(file_id)
_drive_cache[file_id] = (time.monotonic(), content)
return content
def fetch_drive_file_name(file_id):
"""파일 이름은 있으면 좋은 정보일 뿐이라, 못 가져와도 조용히 빈 값으로 둔다."""
try:
from google.auth.transport.requests import AuthorizedSession
session = AuthorizedSession(_load_credentials([DRIVE_SCOPE]))
response = _drive_get(session, file_id, {"fields": "name"})
if response.status_code == 200:
return response.json().get("name", "")
except Exception:
pass
return ""
def _sheet_names_from_bytes(content):
from openpyxl import load_workbook
try:
workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"엑셀 파일을 열지 못했습니다: {exc}")
try:
return list(workbook.sheetnames)
finally:
workbook.close()
def _sheet_tabs_from_excel(file_id):
"""비공개 파일: Drive API로 내려받아 시트 이름을 읽는다."""
content = fetch_drive_workbook(file_id)
return {
"source": "excel",
"file_name": fetch_drive_file_name(file_id),
"sheets": [{"name": name} for name in _sheet_names_from_bytes(content)],
}
def _read_sheet_tabs(file_id):
"""시트 이름 목록을 읽는다. 구글 왕복을 최소로 줄이는 순서로 시도한다.
1) 링크가 공개된 파일이면 xlsx로 한 번에 내려받는다.
(네이티브 구글 시트도 export?format=xlsx 로 받아지므로 대부분 여기서 끝난다)
2) 비공개면 서비스 계정으로 Sheets API → 오피스 파일이면 Drive API
"""
content = _fetch_public_workbook(file_id)
if content is not None:
# 다음 단계(선택한 시트의 값 읽기)에서 다시 받지 않도록 함께 캐시해 둔다
_drive_cache[file_id] = (time.monotonic(), content)
return {
"source": "public_xlsx",
"file_name": "",
"sheets": [{"name": name} for name in _sheet_names_from_bytes(content)],
}
try:
import gspread
except ImportError:
raise HTTPException(status_code=500, detail="gspread 라이브러리가 설치되어 있지 않습니다.")
try:
client = gspread.authorize(_load_credentials([SHEETS_SCOPE]))
spreadsheet = client.open_by_key(file_id)
return {
"source": "google_sheet",
"file_name": spreadsheet.title,
"sheets": [
{"name": ws.title, "rows": ws.row_count, "cols": ws.col_count}
for ws in spreadsheet.worksheets()
],
}
except HTTPException:
raise
except Exception as exc:
message = str(exc)
if "Office file" in message or "not supported for this document" in message:
return _sheet_tabs_from_excel(file_id)
if "PERMISSION_DENIED" in message or "not found" in message.lower() or "404" in message:
raise HTTPException(
status_code=400,
detail=f"파일을 열 수 없습니다. 구글 드라이브에서 '{_service_account_email()}' 계정에 "
f"공유하거나 '링크가 있는 모든 사용자'로 설정해주세요. (원본 메시지: {message[:200]})",
)
raise HTTPException(status_code=400, detail=f"구글 시트를 열지 못했습니다: {message[:300]}")
def _cell_str(value):
"""셀 값을 문자열로. 숫자로 저장된 코드가 '7000.0'이 되지 않게 처리한다."""
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _to_int(value):
"""수량을 정수로. 정수가 아니면 None."""
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value) if value.is_integer() else None
text = _cell_str(value).replace(",", "")
if not text:
return None
try:
return int(float(text))
except ValueError:
return None
def _find_milkrun_columns(rows):
"""'제품코드 / 제품명 / 수량' 제목이 있는 행을 찾아 열 위치를 정한다.
반환: (헤더 행 번호(1-based), {code/name/qty: 열 번호(1-based)})
못 찾으면 사진 기준 기본 위치(H/I/J)와 헤더 5행을 쓴다.
"""
for index, row in enumerate(rows[:MILKRUN_HEADER_SCAN_ROWS]):
texts = [_cell_str(cell) for cell in row]
if MILKRUN_COLUMN_LABELS["code"] in texts and MILKRUN_COLUMN_LABELS["qty"] in texts:
columns = {}
for key, label in MILKRUN_COLUMN_LABELS.items():
columns[key] = (texts.index(label) + 1) if label in texts else MILKRUN_DEFAULT_COLUMNS[key]
return index + 1, columns
return 5, dict(MILKRUN_DEFAULT_COLUMNS)
def _read_milkrun_sheet(content, sheet_name):
"""선택한 시트에서 제품코드/제품명/수량을 끝까지 읽어온다."""
from openpyxl import load_workbook
try:
workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"엑셀 파일을 열지 못했습니다: {exc}")
try:
if sheet_name not in workbook.sheetnames:
raise HTTPException(
status_code=400,
detail=f"'{sheet_name}' 시트를 찾지 못했습니다. 시트 목록을 새로고침해주세요.",
)
sheet = workbook[sheet_name]
rows = list(sheet.iter_rows(values_only=True))
finally:
workbook.close()
header_row, columns = _find_milkrun_columns(rows)
def value_at(row, column):
return row[column - 1] if len(row) >= column else None
items = []
skipped = 0
blank_run = 0
for row in rows[header_row:]: # 헤더 다음 행부터
code = _cell_str(value_at(row, columns["code"]))
name = _cell_str(value_at(row, columns["name"]))
qty = _to_int(value_at(row, columns["qty"]))
if not code and not name and qty is None:
blank_run += 1
if blank_run >= MILKRUN_BLANK_RUN_LIMIT:
break
continue
blank_run = 0
if not code or qty is None or qty <= 0:
skipped += 1
continue
items.append({"code": code, "name": name, "qty": qty})
return {
"sheet": sheet_name,
"header_row": header_row,
"columns": columns,
"items": items,
"skipped": skipped,
# 붙여넣기 입력에 그대로 넣을 수 있는 형태 (탭 구분)
"text": "\n".join(f"{i['code']}\t{i['name']}\t{i['qty']}" for i in items),
}
@router.post("/sheet-rows")
async def get_sheet_rows(payload: SheetRowsRequest):
"""선택한 시트의 제품코드/제품명/수량을 읽어 분석용 텍스트로 돌려준다."""
file_id = extract_file_id(payload.url or MILKRUN_SHEET_URL)
if not payload.sheet:
raise HTTPException(status_code=400, detail="시트를 선택해주세요.")
if payload.refresh:
_drive_cache.pop(file_id, None)
content = fetch_drive_workbook(file_id)
result = _read_milkrun_sheet(content, payload.sheet)
return {"status": "success", "file_id": file_id, **result}
@router.post("/sheet-tabs")
async def get_sheet_tabs(payload: SheetTabsRequest):
"""구글 드라이브 파일의 시트(탭) 이름 목록을 돌려준다."""
file_id = extract_file_id(payload.url or MILKRUN_SHEET_URL)
if not payload.refresh:
cached = _sheet_tabs_cache.get(file_id)
if cached and time.monotonic() - cached[0] < _DRIVE_CACHE_TTL_SECONDS:
return {"status": "success", "file_id": file_id, "cached": True, **cached[1]}
result = _read_sheet_tabs(file_id)
_sheet_tabs_cache[file_id] = (time.monotonic(), result)
return {"status": "success", "file_id": file_id, "cached": False, **result}
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -1,7 +1,7 @@
import os
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
import os
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
import datetime import datetime
@@ -65,16 +65,17 @@ class BulkReturnReceive(BaseModel):
@router.get("/lookup") @router.get("/lookup")
async def return_lookup(tracking_no: str): async def return_lookup(tracking_no: str):
try: try:
tracking_no = tracking_no.strip()
conn = get_order_db_conn() conn = get_order_db_conn()
cur = conn.cursor() cur = conn.cursor()
# 주문일시(order_date 또는 upload_date) 기준 가장 최신 1건 조회 # 주문일시(order_date 또는 upload_date) 기준 가장 최신 1건 조회
cur.execute(r""" cur.execute("""
SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address
FROM orders FROM orders
WHERE tracking_number = %s WHERE tracking_number = %s
OR (tracking_number IS NOT NULL OR (tracking_number IS NOT NULL
AND tracking_number != '' AND tracking_number != ''
AND %s = ANY(regexp_split_to_array(tracking_number, '[,\s/;]+'))) AND %s = ANY(regexp_split_to_array(tracking_number, '[,\\s/;]+')))
ORDER BY order_date DESC LIMIT 1 ORDER BY order_date DESC LIMIT 1
""", (tracking_no, tracking_no)) """, (tracking_no, tracking_no))
row = cur.fetchone() row = cur.fetchone()
@@ -168,13 +169,13 @@ async def create_return_receive_bulk(req: BulkReturnReceive):
else: else:
remarks = "고객이 반품" remarks = "고객이 반품"
# 2. Check orders # 2. Check orders
cur_order.execute(r""" cur_order.execute("""
SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address
FROM orders FROM orders
WHERE tracking_number = %s WHERE tracking_number = %s
OR (tracking_number IS NOT NULL OR (tracking_number IS NOT NULL
AND tracking_number != '' AND tracking_number != ''
AND %s = ANY(regexp_split_to_array(tracking_number, '[,\s/;]+'))) AND %s = ANY(regexp_split_to_array(tracking_number, '[,\\s/;]+')))
ORDER BY order_date DESC LIMIT 1 ORDER BY order_date DESC LIMIT 1
""", (tracking_no, tracking_no)) """, (tracking_no, tracking_no))
ord_row = cur_order.fetchone() ord_row = cur_order.fetchone()
@@ -207,21 +208,21 @@ async def create_return_receive(req: ReturnReceive):
req.tracking_no = req.tracking_no.strip() req.tracking_no = req.tracking_no.strip()
conn = get_return_db_conn() conn = get_return_db_conn()
cur = conn.cursor() cur = conn.cursor()
# 중복 송장 검사 # 중복 송장 검사
if req.id: if req.id:
cur.execute(""" cur.execute("""
SELECT receive_date, receiver_name, phone, mall, receive_status, remarks SELECT receive_date, receiver_name, phone, mall, receive_status, remarks
FROM return_receivings FROM return_receivings
WHERE TRIM(tracking_no) = %s AND id != %s WHERE TRIM(tracking_no) = %s AND id != %s
""", (req.tracking_no, req.id)) """, (req.tracking_no, req.id))
else: else:
cur.execute(""" cur.execute("""
SELECT receive_date, receiver_name, phone, mall, receive_status, remarks SELECT receive_date, receiver_name, phone, mall, receive_status, remarks
FROM return_receivings FROM return_receivings
WHERE TRIM(tracking_no) = %s WHERE TRIM(tracking_no) = %s
""", (req.tracking_no,)) """, (req.tracking_no,))
dup = cur.fetchone() dup = cur.fetchone()
if dup: if dup:
conn.close() conn.close()
@@ -230,7 +231,7 @@ async def create_return_receive(req: ReturnReceive):
"error_type": "duplicate", "error_type": "duplicate",
"data": dict(dup) "data": dict(dup)
} }
if not req.id: if not req.id:
cur.execute("SELECT request_type, remarks FROM return_requests WHERE TRIM(tracking_no) = %s", (req.tracking_no,)) cur.execute("SELECT request_type, remarks FROM return_requests WHERE TRIM(tracking_no) = %s", (req.tracking_no,))
req_row = cur.fetchone() req_row = cur.fetchone()
+853 -30
View File
@@ -107,13 +107,47 @@ p {
} }
.nav-links li { .nav-links li {
/* 탭 글자 크기 */ /* 탭 글자 크기 — 사이드바(130px)에서 모든 메뉴가 한 줄에 들어가는 크기.
padding: 11px 10px; 메뉴는 전부 이 크기 하나로 통일한다. */
padding: 11px 8px;
cursor: pointer; cursor: pointer;
font-weight: 200; font-weight: 200;
font-size: 1.0rem; font-size: 0.8rem;
transition: var(--transition); transition: var(--transition);
border-left: 3px solid transparent; border-left: 3px solid transparent;
/* 어떤 경우에도 두 줄로 넘어가지 않게 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 메뉴 아이콘: 글자 크기와 무관하게 원래 크기(1rem)를 유지한다.
1em이 아니라 rem으로 고정해야 메뉴 글자를 줄여도 아이콘이 같이 작아지지 않는다.
width는 글자 크기가 아니라 아이콘 칸 너비로, 메뉴 글자 시작선을 맞추는 용도. */
.nav-links li .nav-icon {
font-size: 1rem;
line-height: 1;
display: inline-block;
width: 1.25rem;
margin-right: 5px;
text-align: center;
font-style: normal;
vertical-align: -0.1em;
flex-shrink: 0;
/* 흑백 글리프로 떨어지지 않도록 컬러 이모지 폰트를 우선 지정 */
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Segoe UI Symbol", sans-serif;
}
/* '준비중' 같은 상태 뱃지도 좁은 사이드바에 맞춰 축소 */
.nav-links li .nav-badge {
font-size: 0.5rem;
background: #e53e3e;
color: #fff;
padding: 1px 2px;
border-radius: 4px;
margin-left: 3px;
flex-shrink: 0;
white-space: nowrap;
} }
.nav-links li:hover { .nav-links li:hover {
@@ -158,52 +192,787 @@ p {
.main-grid { .main-grid {
display: grid; display: grid;
grid-template-columns: 1fr 525px; grid-template-columns: 1fr 525px;
grid-template-rows: minmax(0, 1fr);
gap: 10px; gap: 10px;
height: calc(100vh - 45px); height: calc(100vh - 45px);
overflow: hidden; overflow: hidden;
} }
/* ★★★ CSS Multi-Column 레이아웃 적용 ★★★ */ .order-workspace {
/* 빈틈없이 차례대로 컬럼을 채우는 가장 강력한 속성 */ min-width: 0;
.left-col { height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: wrap; overflow: hidden;
align-content: flex-start; }
.order-category-panel {
padding: 8px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(203, 213, 224, 0.9);
border-radius: 8px;
}
.order-category-heading {
display: flex;
align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 6px;
color: #2d3748;
}
.order-category-heading strong {
font-size: 0.82rem;
}
.order-category-heading span {
color: #4a5568;
font-size: 0.68rem;
}
.order-group-buttons {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.order-group-button {
min-height: 28px;
padding: 4px 9px;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #cbd5e0;
border-radius: 4px;
background: #fff;
color: #2d3748;
cursor: pointer;
font-size: 0.75rem;
font-weight: 700;
}
.order-group-button:hover,
.order-group-button.has-selection,
.order-group-button.is-active {
border-color: var(--primary-color);
background: #ebf8ff;
}
.order-group-count {
min-width: 18px;
height: 18px;
padding: 0 4px;
display: none;
align-items: center;
justify-content: center;
border-radius: 9px;
background: var(--primary-color);
color: #fff;
font-size: 0.65rem;
line-height: 1;
}
.order-group-button.has-selection .order-group-count {
display: inline-flex;
}
.order-workspace-body {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(270px, 38%);
gap: 10px;
margin-top: 8px;
}
.order-summary-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.order-summary-column .order-selected-panel {
flex: 1;
}
.new-cs-options-slot {
display: none;
}
.order-items-inline-host {
min-width: 0;
min-height: 0;
padding: 8px;
overflow: hidden;
border: 1px solid #cbd5e0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.32);
}
.order-items-inline-empty {
height: 100%; height: 100%;
overflow-x: auto; display: flex;
overflow-y: hidden; flex-direction: column;
padding-bottom: 5px; align-items: center;
justify-content: center;
gap: 7px;
color: #4a5568;
font-size: 0.78rem;
text-align: center;
} }
.left-col::-webkit-scrollbar { .order-items-inline-empty span:first-child {
width: 5px; font-size: 2rem;
} }
.left-col::-webkit-scrollbar-thumb { .order-selected-panel {
background: rgba(0, 0, 0, 0.2); min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 9px;
border: 1px solid #cbd5e0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.82);
overflow: hidden;
}
.order-selected-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 6px;
border-bottom: 2px solid var(--primary-color);
}
.order-selected-heading h3 {
margin: 0;
padding: 0;
border: 0;
font-size: 0.85rem;
}
.order-selected-heading span {
color: #2b6cb0;
font-size: 0.72rem;
font-weight: 700;
}
.order-selected-items {
min-height: 0;
flex: 1;
padding-top: 5px;
overflow-y: auto;
}
.order-selected-empty {
padding: 20px 5px;
color: #718096;
font-size: 0.72rem;
text-align: center;
}
.order-selected-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
padding: 6px 3px;
border-bottom: 1px solid #e2e8f0;
font-size: 0.72rem;
}
.order-selected-remove {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 3px; border-radius: 3px;
background: transparent;
color: #e53e3e;
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.order-selected-remove:hover {
background: #fff5f5;
}
.order-selected-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.order-selected-qty {
width: 58px;
height: 24px;
padding: 1px 3px;
border: 1px solid #cbd5e0;
border-radius: 4px;
background: #fff;
color: #2b6cb0;
font-size: 0.7rem;
font-weight: 700;
white-space: nowrap;
}
.order-selected-price {
min-width: 68px;
color: #2d3748;
font-weight: 700;
text-align: right;
white-space: nowrap;
}
.order-selected-totals {
padding: 8px 2px;
border-top: 2px solid #cbd5e0;
flex-shrink: 0;
}
.order-selected-totals > div {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 1px;
font-size: 0.78rem;
}
.order-selected-totals strong {
color: #e53e3e;
font-size: 0.85rem;
}
.order-selected-totals > .order-summary-adjustments {
display: block;
padding: 0;
}
.order-summary-adjustments > div {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 1px;
color: #4a5568;
font-size: 0.72rem;
}
.order-summary-adjustments b {
color: #2d3748;
}
.order-selected-totals > .order-summary-final {
margin-top: 3px;
padding-top: 5px;
border-top: 1px dashed #cbd5e0;
}
.order-preview-button {
width: 100%;
padding: 7px;
flex-shrink: 0;
}
.order-items-inline-host .order-item-group {
width: 100%;
height: 100%;
display: none;
flex-direction: column;
padding: 8px;
overflow: hidden;
background: rgba(255, 255, 255, 0.9);
}
.order-items-inline-host .order-item-group.is-active {
display: flex;
}
.order-item-group .order-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 6px;
border-bottom: 2px solid var(--primary-color);
flex-shrink: 0;
}
.order-item-group .order-popup-header h3 {
margin: 0;
padding: 0;
border: 0;
font-size: 0.9rem;
}
.order-item-group .item-list {
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 3px 8px;
padding: 8px 3px 2px 0;
overflow-y: auto;
}
.order-item-subgroup {
display: contents;
}
.order-item-subgroup-title {
display: none;
}
.classic-product-column {
display: contents;
}
.order-item-group .item-row {
min-width: 0;
background: #f7fafc;
}
.sms-preview-modal {
position: fixed;
z-index: 2600;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.52);
}
.sms-preview-modal.is-open {
display: flex;
}
.sms-preview-dialog {
width: min(520px, calc(100vw - 30px));
height: min(760px, calc(100vh - 40px));
display: flex;
flex-direction: column;
padding: 12px;
overflow: hidden;
background: #fff;
}
.sms-preview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary-color);
flex-shrink: 0;
}
.sms-preview-heading h3 {
margin: 0;
padding: 0;
border: 0;
font-size: 1rem;
}
#sms-preview {
min-height: 0;
flex: 1;
margin-top: 10px;
overflow-y: auto;
} }
.right-col { .right-col {
display: grid; display: grid;
grid-template-columns: 260px 250px; grid-template-columns: 260px 250px;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 15px; gap: 15px;
height: 100%; height: 100%;
} }
.right-panel-col1 { .right-panel-col1 {
display: flex; display: contents;
flex-direction: column;
gap: 8px;
} }
.right-panel-col2 { .right-panel-col2 {
display: contents;
}
.right-col .customer-info {
grid-column: 2;
grid-row: 1;
min-height: 0;
}
.right-col .history-box {
grid-column: 1;
grid-row: 1;
height: auto !important;
min-height: 0;
margin-bottom: 0 !important;
}
.right-col .manual-history-box {
grid-column: 1;
grid-row: 2;
height: auto !important;
min-height: 0;
}
.right-col .sms-options {
grid-column: 2;
grid-row: 2;
align-self: end;
}
/* New CS 전용 배치: 기존 CS 작업 화면과 입력 요소를 공유합니다. */
#order-tab.order-view-new .main-grid {
grid-template-columns: minmax(0, 1fr) 260px;
}
#order-tab.order-view-new .order-category-heading strong {
font-size: 0.98rem;
}
#order-tab.order-view-new .order-category-heading span {
font-size: 0.82rem;
}
#order-tab.order-view-new .order-group-button {
min-height: 34px;
padding: 5px 11px;
font-size: 0.9rem;
}
#order-tab.order-view-new .order-group-count {
min-width: 21px;
height: 21px;
border-radius: 11px;
font-size: 0.78rem;
}
#order-tab.order-view-new .order-workspace-body {
grid-template-columns: minmax(0, 1fr) 370px;
}
#order-tab.order-view-new .order-summary-column {
width: 370px;
gap: 10px;
}
#order-tab.order-view-new .new-cs-options-slot {
display: block;
flex: 0 0 auto;
}
#order-tab.order-view-new .new-cs-options-slot .sms-options {
width: 370px;
}
#order-tab.order-view-new .right-col {
display: block;
min-width: 0;
}
#order-tab.order-view-new .right-panel-col1 {
height: 100%;
display: grid;
grid-template-rows: 370px minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
#order-tab.order-view-new .right-col .customer-info,
#order-tab.order-view-new .right-col .history-box,
#order-tab.order-view-new .right-col .manual-history-box {
grid-column: 1;
min-height: 0;
height: auto !important;
margin: 0 !important;
}
#order-tab.order-view-new .right-col .customer-info {
grid-row: 1;
height: 370px !important;
overflow-y: auto;
}
#order-tab.order-view-new .customer-info > h3 {
margin-bottom: 4px !important;
}
#order-tab.order-view-new .customer-info .form-group {
margin-bottom: 3px;
}
#order-tab.order-view-new .customer-info .form-group input {
padding-top: 3px;
padding-bottom: 3px;
}
#order-tab.order-view-new .customer-info .radio-group.mt-1,
#order-tab.order-view-new .customer-info .action-buttons.mt-1 {
margin-top: 2px;
}
#order-tab.order-view-new .customer-info .btn {
padding-top: 5px;
padding-bottom: 5px;
}
#order-tab.order-view-new .right-col .history-box {
grid-row: 2;
}
#order-tab.order-view-new .right-col .manual-history-box {
grid-row: 3;
}
#order-tab.order-view-new #cust-address {
height: 100px;
min-height: 100px;
resize: vertical;
}
#order-tab.order-view-new .order-item-group .item-list {
grid-template-columns: repeat(var(--subgroup-count, 1), minmax(0, 250px));
align-items: start;
justify-content: start;
gap: 12px;
overflow-x: auto;
}
#order-tab.order-view-new .order-item-subgroup {
width: 100%;
min-width: 180px;
max-width: 250px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 3px;
padding: 6px;
overflow: hidden;
border: 1px solid #cbd5e0;
border-radius: 7px;
background: #f8fafc;
box-shadow: 0 1px 3px rgba(45, 55, 72, 0.08);
}
#order-tab.order-view-new .order-item-subgroup-title {
min-height: 28px;
display: flex;
align-items: center;
margin: -6px -6px 3px;
padding: 5px 8px;
overflow: hidden;
border-bottom: 1px solid #bee3f8;
border-left: 4px solid #3182ce;
background: #ebf8ff;
color: #2d3748;
font-size: 0.78rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
#order-tab.order-view-new .order-item-subgroup-title-text {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#order-tab.order-view-new .order-item-subgroup-edit {
width: 24px;
height: 22px;
flex: 0 0 24px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 5px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
font-size: 0.78rem;
cursor: pointer;
}
#order-tab.order-view-new .order-item-subgroup-edit:hover,
#order-tab.order-view-new .order-item-subgroup-edit:focus-visible {
border-color: #90cdf4;
background: #fff;
outline: none;
}
#order-tab.order-view-new .order-item-subgroup-title-input {
width: 100%;
height: 24px;
padding: 2px 6px;
border: 1px solid #3182ce;
border-radius: 4px;
background: #fff;
color: #1a202c;
font-size: 0.78rem;
font-weight: 700;
outline: none;
}
#order-tab.order-view-new .order-preview-button {
border: 1px solid #cbd5e0;
background: #e2e8f0;
color: #4a5568;
}
#order-tab.order-view-new .order-preview-button:hover {
background: #cbd5e0;
}
/* CS 작업: 상품 전체가 펼쳐지는 기존 UI */
#order-tab.order-view-classic .order-category-panel,
#order-tab.order-view-classic .order-summary-column {
display: none;
}
#order-tab.order-view-classic .order-workspace-body {
height: 100%; height: 100%;
display: block;
margin-top: 0;
}
#order-tab.order-view-classic .order-items-inline-host {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(6, max-content);
align-content: start;
align-items: start;
gap: 8px;
padding: 0;
overflow: auto;
border: 0;
border-radius: 0;
background: transparent;
}
#order-tab.order-view-classic .classic-product-column {
width: max-content;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
#order-tab.order-view-classic .order-items-inline-empty {
display: none !important;
}
#order-tab.order-view-classic .order-items-inline-host .order-item-group {
width: max-content;
max-width: none;
height: auto;
display: flex;
padding: 8px;
}
#order-tab.order-view-classic .order-item-group .item-list,
#order-tab.order-view-classic .order-item-subgroup {
display: flex;
flex-direction: column;
gap: 1px;
}
#order-tab.order-view-classic .order-item-subgroup + .order-item-subgroup {
margin-top: 8px;
padding-top: 8px;
border-top: 1.5px dashed #1a202c;
}
#order-tab.order-view-classic .right-col {
display: grid;
grid-template-columns: 260px 250px;
grid-template-rows: minmax(0, 1fr);
gap: 15px;
}
#order-tab.order-view-classic .right-panel-col1,
#order-tab.order-view-classic .right-panel-col2 {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
}
#order-tab.order-view-classic .right-panel-col1 {
grid-column: 1;
grid-row: 1;
}
#order-tab.order-view-classic .right-panel-col2 {
grid-column: 2;
grid-row: 1;
}
#order-tab.order-view-classic .right-col .customer-info {
width: 100%;
height: 330px !important;
flex: 0 0 330px;
}
#order-tab.order-view-classic .right-col .history-box {
width: 100%;
height: 260px !important;
flex: 0 0 260px;
}
#order-tab.order-view-classic .right-col .manual-history-box {
width: 100%;
height: auto !important;
flex: 1 1 0;
}
#order-tab.order-view-classic .sms-preview-modal {
position: static;
width: 100%;
height: auto;
flex: 1 1 0;
min-height: 0;
display: block;
padding: 8px;
overflow: hidden;
border: 1px solid var(--card-border);
border-radius: 8px;
background: var(--card-bg);
box-shadow: var(--card-shadow);
}
#order-tab.order-view-classic .sms-preview-dialog {
width: 100%;
height: 100%;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
box-shadow: none;
}
#order-tab.order-view-classic .sms-preview-heading {
display: none;
}
#order-tab.order-view-classic #sms-preview {
height: 100%;
margin: 0;
}
#order-tab.order-view-classic .right-col .sms-options {
width: 100%;
flex: 0 0 auto;
align-self: auto;
} }
.right-col::-webkit-scrollbar { .right-col::-webkit-scrollbar {
@@ -732,6 +1501,11 @@ p {
border-left-color: transparent; border-left-color: transparent;
border-bottom-color: var(--primary-color); border-bottom-color: var(--primary-color);
} }
/* 가로 스크롤 메뉴에서는 아이콘 칸을 좁혀 폭을 아낀다 */
.nav-links li .nav-icon {
width: auto;
margin-right: 4px;
}
/* 3. Main Grid & Left Col (Product List) */ /* 3. Main Grid & Left Col (Product List) */
.main-grid { .main-grid {
@@ -740,16 +1514,13 @@ p {
height: auto; height: auto;
gap: 15px; gap: 15px;
} }
.left-col { .order-workspace {
height: 45vh; /* Fixed height with internal scroll */ height: auto;
flex-direction: column; min-height: 80vh;
flex-wrap: nowrap; }
overflow-y: auto; .order-workspace-body {
overflow-x: hidden; grid-template-columns: 1fr;
border: 1px solid rgba(0, 0, 0, 0.1); grid-template-rows: minmax(45vh, 1fr) minmax(35vh, auto);
padding: 5px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
} }
.item-group-card { .item-group-card {
width: 100%; width: 100%;
@@ -764,6 +1535,9 @@ p {
gap: 15px; gap: 15px;
} }
.right-panel-col1, .right-panel-col2 { .right-panel-col1, .right-panel-col2 {
display: flex;
flex-direction: column;
gap: 15px;
width: 100%; width: 100%;
height: auto; height: auto;
} }
@@ -1096,4 +1870,53 @@ p {
.toggle-switch input:checked + .switch-label:before { .toggle-switch input:checked + .switch-label:before {
transform: translateX(26px); transform: translateX(26px);
} }
/* 반품 송장 일괄 입력: 편집기처럼 입력 줄과 함께 움직이는 줄 번호 */
.bulk-tracking-editor {
--bulk-tracking-border: #cbd5e0;
position: relative;
width: 100%;
}
.bulk-tracking-line-numbers {
position: absolute;
z-index: 1;
top: 1px;
bottom: 1px;
left: 1px;
width: 38px;
padding: 10px 7px 10px 4px;
overflow: hidden;
border-right: 1px solid var(--bulk-tracking-border);
border-radius: 3px 0 0 3px;
background: #f7fafc;
color: #718096;
font-family: monospace;
font-size: 13px;
line-height: 20px;
text-align: right;
white-space: pre;
user-select: none;
pointer-events: none;
}
.bulk-tracking-input {
display: block;
width: 100%;
height: 200px;
padding: 10px 10px 10px 48px;
overflow: auto;
border: 1px solid var(--bulk-tracking-border);
border-radius: 4px;
resize: vertical;
font-family: monospace;
font-size: 13px;
line-height: 20px;
white-space: pre;
}
.bulk-tracking-input:focus {
outline: none;
border-color: var(--bulk-tracking-border);
}
+1243 -120
View File
File diff suppressed because it is too large Load Diff
+841
View File
@@ -0,0 +1,841 @@
// 자사몰 행사 탭 - 카페24 주문/발주 파일 가공
//
// 업로드 → 조건 선택 → 미리보기 → 가공된 엑셀 다운로드 흐름을 담당한다.
// 기능이 늘어나면 initFirstComeGift() 처럼 기능별 초기화 함수를 추가하고
// 왼쪽 '기능' 목록 버튼(.me-feature-btn)과 패널(#me-panel-<feature>)을 연결한다.
(function () {
'use strict';
document.addEventListener('DOMContentLoaded', function () {
const tab = document.getElementById('mall-event-tab');
if (!tab) return;
initFeatureSwitcher();
initFirstComeGift();
initEventExtract();
});
// ------------------------------------------------------------------
// 기능 공통 헬퍼
// ------------------------------------------------------------------
function escapeHtml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function normalize(str) {
return String(str || '').toLowerCase().replace(/\s+/g, '');
}
async function readError(res, fallback) {
// 413은 앱이 아니라 앞단 웹서버(nginx 등)가 용량 제한으로 막은 것이라
// 응답에 이유가 없다. 원인을 바로 알 수 있게 따로 안내한다.
if (res.status === 413) {
return '파일이 너무 커서 서버가 업로드를 거부했습니다 (413). ' +
'서버 웹서버(nginx 등)의 업로드 용량 제한(client_max_body_size)을 늘려야 합니다.';
}
try {
const data = await res.json();
if (data && data.detail) {
// FastAPI 검증 오류는 detail이 배열로 온다
if (Array.isArray(data.detail)) {
return data.detail.map(function (d) { return d.msg || JSON.stringify(d); }).join(', ');
}
return data.detail;
}
} catch (e) { /* 본문이 JSON이 아니면 아래 기본 메시지 */ }
// 서버 오류(500 등)는 본문이 없으므로 상태 코드라도 남겨 원인을 좁힌다
return fallback + ' (서버 응답 ' + res.status + ')';
}
// 서버가 Content-Disposition으로 내려준 파일명을 꺼낸다
function filenameFromResponse(res, fallback) {
const disposition = res.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (match) {
try { return decodeURIComponent(match[1]); } catch (e) { /* 무시 */ }
}
return fallback;
}
// 탭 전체를 덮는 로딩 오버레이 (기능들이 공유)
function setOverlay(isLoading, text) {
const overlay = document.getElementById('me-loading-overlay');
const overlayText = document.getElementById('me-loading-text');
if (overlay) overlay.style.display = isLoading ? 'flex' : 'none';
if (overlayText && text) overlayText.textContent = text;
}
// 파일 선택 버튼 + 드래그 앤 드롭을 한 번에 붙인다
function bindDropzone(dropzone, fileInput, pickBtn, onFile) {
if (!dropzone || !fileInput) return;
if (pickBtn) {
pickBtn.addEventListener('click', function (e) {
e.stopPropagation();
fileInput.click();
});
}
dropzone.addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files && fileInput.files[0]) onFile(fileInput.files[0]);
});
['dragenter', 'dragover'].forEach(function (type) {
dropzone.addEventListener(type, function (e) {
e.preventDefault();
e.stopPropagation();
dropzone.style.background = '#ebf8ff';
dropzone.style.borderColor = '#3182ce';
});
});
['dragleave', 'drop'].forEach(function (type) {
dropzone.addEventListener(type, function (e) {
e.preventDefault();
e.stopPropagation();
dropzone.style.background = '#f7fafc';
dropzone.style.borderColor = '#cbd5e0';
});
});
dropzone.addEventListener('drop', function (e) {
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length > 0) onFile(files[0]);
});
}
// 탭 밖에 파일을 떨어뜨렸을 때 브라우저가 파일을 열어버리는 것 방지
['dragover', 'drop'].forEach(function (type) {
window.addEventListener(type, function (e) {
const inDropzone = e.target.closest && e.target.closest('#me-dropzone, #ee-dropzone');
if (!inDropzone) e.preventDefault();
});
});
// blob 응답을 파일로 저장
async function saveBlob(res, fallbackName) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFromResponse(res, fallbackName);
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
}
// ------------------------------------------------------------------
// 기능 전환
// ------------------------------------------------------------------
function initFeatureSwitcher() {
const buttons = document.querySelectorAll('.me-feature-btn');
if (buttons.length < 2) return; // 기능이 하나면 전환할 것이 없다
buttons.forEach(function (btn) {
btn.addEventListener('click', function () {
buttons.forEach(function (b) {
b.classList.remove('active');
b.style.background = '#fff';
b.style.color = '#4a5568';
b.style.borderColor = '#e2e8f0';
});
btn.classList.add('active');
btn.style.background = '#ebf8ff';
btn.style.color = '#2b6cb0';
btn.style.borderColor = '#bee3f8';
document.querySelectorAll('.me-feature-panel').forEach(function (panel) {
panel.style.display = 'none';
});
const target = document.getElementById('me-panel-' + btn.dataset.feature);
if (target) target.style.display = 'flex';
});
});
}
// ------------------------------------------------------------------
// 기능 1) 선착순 사은품 추가
// ------------------------------------------------------------------
function initFirstComeGift() {
const dropzone = document.getElementById('me-dropzone');
const fileInput = document.getElementById('me-file-input');
const pickBtn = document.getElementById('btn-me-pick-file');
const resetBtn = document.getElementById('btn-me-reset');
const fileInfo = document.getElementById('me-file-info');
const fileError = document.getElementById('me-file-error');
const orderListBox = document.getElementById('me-order-list');
const orderSearch = document.getElementById('me-order-search');
const selectAllBtn = document.getElementById('btn-me-select-all');
const selectNoneBtn = document.getElementById('btn-me-select-none');
const selectedInfo = document.getElementById('me-order-selected-info');
const limitInput = document.getElementById('me-limit');
const giftSelect = document.getElementById('me-gift-select');
const giftQtyInput = document.getElementById('me-gift-qty');
const applyBtn = document.getElementById('btn-me-apply');
const warningBox = document.getElementById('me-warning-box');
const resultInfo = document.getElementById('me-result-info');
const resultTbody = document.getElementById('me-result-tbody');
const downloadFullBtn = document.getElementById('btn-me-download-full');
const downloadGiftBtn = document.getElementById('btn-me-download-gift');
const overlay = document.getElementById('me-loading-overlay');
const overlayText = document.getElementById('me-loading-text');
if (!dropzone || !fileInput) return;
let currentFile = null;
let orderItems = []; // [{name, order_count}]
const selectedNames = new Set();
let searchKeyword = '';
let previewReady = false;
function setLoading(isLoading, text) {
setOverlay(isLoading, text);
applyBtn.disabled = isLoading;
}
function showFileError(message) {
if (!fileError) return;
if (!message) {
fileError.style.display = 'none';
fileError.textContent = '';
return;
}
fileError.style.display = 'block';
fileError.textContent = message;
}
// ---- 다운로드 버튼 활성/비활성 ----
function setDownloadEnabled(enabled) {
previewReady = enabled;
[downloadFullBtn, downloadGiftBtn].forEach(function (btn) {
if (!btn) return;
btn.disabled = !enabled;
btn.style.opacity = enabled ? '1' : '0.5';
btn.style.cursor = enabled ? 'pointer' : 'default';
});
}
// 조건이 바뀌면 이전 미리보기 결과는 더 이상 유효하지 않다
function invalidatePreview() {
if (!previewReady) return;
setDownloadEnabled(false);
resultInfo.innerHTML = '<span style="color:#975a16;">조건이 바뀌었습니다. "사은품 적용 결과 보기"를 다시 눌러주세요.</span>';
}
// ---- 파일 업로드 ----
function resetAll() {
currentFile = null;
orderItems = [];
selectedNames.clear();
searchKeyword = '';
fileInput.value = '';
if (orderSearch) orderSearch.value = '';
fileInfo.style.display = 'none';
fileInfo.innerHTML = '';
showFileError('');
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">발주서를 업로드하면 주문 목록이 표시됩니다.</div>';
selectedInfo.textContent = '';
resultTbody.innerHTML = '';
resultInfo.textContent = '';
warningBox.style.display = 'none';
warningBox.innerHTML = '';
setDownloadEnabled(false);
}
async function handleFile(file) {
if (!file) return;
const lower = file.name.toLowerCase();
if (lower.endsWith('.xls')) {
showFileError('구형 .xls 파일은 처리할 수 없습니다. 엑셀에서 .xlsx로 저장한 뒤 올려주세요.');
return;
}
if (!lower.endsWith('.xlsx') && !lower.endsWith('.xlsm')) {
showFileError('엑셀 파일(.xlsx)만 업로드할 수 있습니다.');
return;
}
showFileError('');
currentFile = file;
selectedNames.clear();
searchKeyword = '';
if (orderSearch) orderSearch.value = '';
setDownloadEnabled(false);
resultTbody.innerHTML = '';
resultInfo.textContent = '';
warningBox.style.display = 'none';
setLoading(true, '발주서를 분석하는 중...');
try {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/mall-event/first-come/analyze', { method: 'POST', body: form });
if (!res.ok) {
showFileError(await readError(res, '발주서를 분석하지 못했습니다.'));
currentFile = null;
return;
}
const data = await res.json();
orderItems = data.order_list_items || [];
fileInfo.style.display = 'block';
fileInfo.innerHTML =
'<div style="font-weight:600; word-break:break-all;">📄 ' + escapeHtml(file.name) + '</div>' +
'<div style="color:#718096;">시트: ' + escapeHtml(data.sheet_name) + ' · 헤더 ' + data.header_row + '행</div>' +
'<div style="color:#718096;">데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
Number(data.total_orders).toLocaleString() + '건 · 상품 ' + orderItems.length + '종</div>';
renderOrderList();
} catch (err) {
console.error(err);
showFileError('업로드 중 오류가 발생했습니다.');
currentFile = null;
} finally {
setLoading(false);
}
}
bindDropzone(dropzone, fileInput, pickBtn, handleFile);
if (resetBtn) resetBtn.addEventListener('click', resetAll);
// ---- 주문목록 리스트 ----
function renderOrderList() {
if (!orderItems.length) {
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">Q열(주문목록)에서 상품을 찾지 못했습니다.</div>';
updateSelectedInfo();
return;
}
const visible = orderItems.filter(function (item) {
return !searchKeyword || normalize(item.name).includes(searchKeyword);
});
if (!visible.length) {
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</div>';
updateSelectedInfo();
return;
}
orderListBox.innerHTML = visible.map(function (item) {
const checked = selectedNames.has(item.name) ? ' checked' : '';
return '' +
'<label class="me-order-row" style="display:flex; align-items:center; gap:8px; padding:5px 8px; border-bottom:1px solid #edf2f7; cursor:pointer; font-size:0.85rem;">' +
'<input type="checkbox" class="me-order-check" value="' + escapeHtml(item.name) + '"' + checked + ' style="width:15px; height:15px; cursor:pointer;">' +
'<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(item.name) + '</span>' +
'<span style="color:#3182ce; font-weight:600; white-space:nowrap;">' + Number(item.order_count).toLocaleString() + '건</span>' +
'</label>';
}).join('');
updateSelectedInfo();
}
function updateSelectedInfo() {
if (!selectedNames.size) {
selectedInfo.textContent = orderItems.length ? '선택된 상품이 없습니다.' : '';
return;
}
const names = Array.from(selectedNames);
const preview = names.slice(0, 2).join(', ');
const more = names.length > 2 ? ' 외 ' + (names.length - 2) + '개' : '';
selectedInfo.innerHTML = '선택 <b style="color:#3182ce;">' + names.length + '개</b> — ' + escapeHtml(preview + more);
}
orderListBox.addEventListener('change', function (e) {
const check = e.target.closest('.me-order-check');
if (!check) return;
if (check.checked) selectedNames.add(check.value);
else selectedNames.delete(check.value);
updateSelectedInfo();
invalidatePreview();
});
if (orderSearch) {
orderSearch.addEventListener('input', function () {
searchKeyword = normalize(orderSearch.value);
renderOrderList();
});
}
if (selectAllBtn) {
selectAllBtn.addEventListener('click', function () {
orderItems.forEach(function (item) {
if (!searchKeyword || normalize(item.name).includes(searchKeyword)) selectedNames.add(item.name);
});
renderOrderList();
invalidatePreview();
});
}
if (selectNoneBtn) {
selectNoneBtn.addEventListener('click', function () {
selectedNames.clear();
renderOrderList();
invalidatePreview();
});
}
// ---- 사은품 목록 (코드표 세트 코드 중 이름에 '사은품'이 들어간 것만) ----
const GIFT_NAME_KEYWORD = '사은품';
async function loadGiftOptions() {
try {
const res = await fetch('/api/codes/set');
if (!res.ok) throw new Error('status ' + res.status);
const data = await res.json();
const allSets = (data && data.data) || [];
const sets = allSets.filter(function (s) {
return String(s.name || '').includes(GIFT_NAME_KEYWORD);
});
if (!sets.length) {
giftSelect.innerHTML = '<option value="">이름에 "' + GIFT_NAME_KEYWORD +
'"이 들어간 세트가 코드표에 없습니다</option>';
return;
}
sets.sort(function (a, b) {
return String(a.name || '').localeCompare(String(b.name || ''), 'ko');
});
giftSelect.innerHTML = '<option value="">사은품을 선택하세요</option>' + sets.map(function (s) {
return '<option value="' + escapeHtml(s.item_code) + '" data-name="' + escapeHtml(s.name || '') + '">' +
escapeHtml(s.name || '(이름 없음)') + ' — ' + escapeHtml(s.item_code) + '</option>';
}).join('');
} catch (err) {
console.error(err);
giftSelect.innerHTML = '<option value="">세트 코드를 불러오지 못했습니다 (DB 연결 확인)</option>';
}
}
loadGiftOptions();
// 코드표 탭에서 세트를 수정하고 돌아왔을 때 목록을 새로 받는다
const mallEventMenu = document.querySelector('[data-tab="mall-event-tab"]');
if (mallEventMenu) {
mallEventMenu.addEventListener('click', function () {
if (!giftSelect.value) loadGiftOptions();
});
}
// ---- 조건 변경 감지 ----
document.querySelectorAll('.me-limit-preset').forEach(function (btn) {
btn.addEventListener('click', function () {
limitInput.value = btn.dataset.value;
invalidatePreview();
});
});
[limitInput, giftSelect, giftQtyInput].forEach(function (el) {
if (el) el.addEventListener('change', invalidatePreview);
});
if (limitInput) limitInput.addEventListener('input', invalidatePreview);
if (giftQtyInput) giftQtyInput.addEventListener('input', invalidatePreview);
// ---- 조건 수집/검증 ----
function buildFormData() {
if (!currentFile) {
alert('먼저 발주서 엑셀 파일을 업로드해주세요.');
return null;
}
if (!selectedNames.size) {
alert('사은품이 지급되는 주문을 최소 1개 선택해주세요.');
return null;
}
const limit = parseInt(limitInput.value, 10);
if (!limit || limit < 1) {
alert('선착순 인원을 1명 이상으로 입력해주세요.');
return null;
}
const giftCode = giftSelect.value;
if (!giftCode) {
alert('사은품(세트 코드)을 선택해주세요.');
return null;
}
const giftQty = parseInt(giftQtyInput.value, 10);
if (!giftQty || giftQty < 1) {
alert('사은품 수량을 1개 이상으로 입력해주세요.');
return null;
}
const giftName = giftSelect.options[giftSelect.selectedIndex].dataset.name || '';
const form = new FormData();
form.append('file', currentFile);
form.append('selected_json', JSON.stringify(Array.from(selectedNames)));
form.append('limit', String(limit));
form.append('gift_item_code', giftCode);
form.append('gift_name', giftName);
form.append('gift_qty', String(giftQty));
return form;
}
// ---- 미리보기 ----
applyBtn.addEventListener('click', async function () {
const form = buildFormData();
if (!form) return;
setLoading(true, '사은품 대상을 계산하는 중...');
try {
const res = await fetch('/api/mall-event/first-come/preview', { method: 'POST', body: form });
if (!res.ok) {
alert(await readError(res, '사은품 적용 중 오류가 발생했습니다.'));
return;
}
const data = await res.json();
if (data.warnings && data.warnings.length) {
warningBox.style.display = 'block';
warningBox.innerHTML = data.warnings.map(function (w) {
return '⚠ ' + escapeHtml(w);
}).join('<br>');
} else {
warningBox.style.display = 'none';
warningBox.innerHTML = '';
}
const added = data.added || [];
if (!added.length) {
resultTbody.innerHTML = '<tr><td colspan="4" style="padding:14px; text-align:center; color:#a0aec0;">사은품이 적용된 주문이 없습니다.</td></tr>';
resultInfo.innerHTML = '<span style="color:#c53030;">적용 대상 0건</span>';
setDownloadEnabled(false);
return;
}
resultTbody.innerHTML = added.map(function (row, idx) {
return '<tr>' +
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; color:#a0aec0;">' + (idx + 1) + '</td>' +
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap;">' + escapeHtml(row.order_no) + '</td>' +
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap; color:#4a5568;">' + escapeHtml(row.order_date) + '</td>' +
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap; color:#2b6cb0;">' + escapeHtml(row.seq) + '</td>' +
'</tr>';
}).join('');
resultInfo.innerHTML = '조건 충족 주문 <b>' + Number(data.matched_order_count).toLocaleString() + '건</b> 중 ' +
'<b style="color:#3182ce;">' + Number(data.applied_count).toLocaleString() + '건</b>에 사은품 행 추가';
setDownloadEnabled(true);
} catch (err) {
console.error(err);
alert('사은품 적용 요청 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
});
// ---- 다운로드 ----
async function download(mode, fallbackName, label) {
const form = buildFormData();
if (!form) return;
form.append('mode', mode);
setLoading(true, label + ' 파일을 만드는 중...');
try {
const res = await fetch('/api/mall-event/first-come/download', { method: 'POST', body: form });
if (!res.ok) {
alert(await readError(res, '엑셀 다운로드 중 오류가 발생했습니다.'));
return;
}
await saveBlob(res, fallbackName);
} catch (err) {
console.error(err);
alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}
// 서버가 Content-Disposition으로 이름을 내려주지만, 못 읽었을 때 쓸 대비책
function baseName() {
const name = (currentFile && currentFile.name) || '발주서.xlsx';
return name.replace(/\.(xlsx|xlsm|xls)$/i, '');
}
if (downloadFullBtn) {
downloadFullBtn.addEventListener('click', function () {
if (downloadFullBtn.disabled) return;
// 전체 발주 파일은 업로드한 파일과 같은 이름으로 받는다
download('full', baseName() + '.xlsx', '전체 발주');
});
}
if (downloadGiftBtn) {
downloadGiftBtn.addEventListener('click', function () {
if (downloadGiftBtn.disabled) return;
download('gift', baseName() + '_선착순사은품_대상주문.xlsx', '사은품 대상 주문');
});
}
setDownloadEnabled(false);
}
// ------------------------------------------------------------------
// 기능 2) 행사 주문 추출
// 주문 CSV → K열(주문상품명) 첫 " -" 앞부분으로 상품 선택 →
// 해당상품만/해당주문 조건으로 행을 걸러 엑셀로 내려받는다.
// ------------------------------------------------------------------
function initEventExtract() {
const dropzone = document.getElementById('ee-dropzone');
const fileInput = document.getElementById('ee-file-input');
const pickBtn = document.getElementById('btn-ee-pick-file');
const resetBtn = document.getElementById('btn-ee-reset');
const fileInfo = document.getElementById('ee-file-info');
const fileError = document.getElementById('ee-file-error');
const productListBox = document.getElementById('ee-product-list');
const productSearch = document.getElementById('ee-product-search');
const selectAllBtn = document.getElementById('btn-ee-select-all');
const selectNoneBtn = document.getElementById('btn-ee-select-none');
const selectedInfo = document.getElementById('ee-selected-info');
const warningBox = document.getElementById('ee-warning-box');
const resultInfo = document.getElementById('ee-result-info');
const downloadBtn = document.getElementById('btn-ee-download');
const modeRadios = document.querySelectorAll('input[name="ee-mode"]');
if (!dropzone || !fileInput) return;
let currentFile = null;
let productItems = []; // [{name, row_count, order_count}]
const selectedNames = new Set();
let searchKeyword = '';
function setLoading(isLoading, text) {
setOverlay(isLoading, text);
downloadBtn.disabled = isLoading;
downloadBtn.style.opacity = isLoading ? '0.6' : '1';
}
// 조건이 바뀌면 직전 다운로드 결과 안내는 지운다
function clearResult() {
resultInfo.innerHTML = '';
}
function currentMode() {
const checked = document.querySelector('input[name="ee-mode"]:checked');
return checked ? checked.value : 'product';
}
function showFileError(message) {
if (!fileError) return;
fileError.style.display = message ? 'block' : 'none';
fileError.textContent = message || '';
}
function resetAll() {
currentFile = null;
productItems = [];
selectedNames.clear();
searchKeyword = '';
fileInput.value = '';
if (productSearch) productSearch.value = '';
fileInfo.style.display = 'none';
fileInfo.innerHTML = '';
showFileError('');
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">CSV를 업로드하면 상품 목록이 표시됩니다.</div>';
selectedInfo.textContent = '';
resultInfo.innerHTML = '';
warningBox.style.display = 'none';
warningBox.innerHTML = '';
clearResult();
}
// ---- 업로드 & 분석 ----
async function handleFile(file) {
if (!file) return;
if (!file.name.toLowerCase().endsWith('.csv')) {
showFileError('CSV 파일(.csv)만 업로드할 수 있습니다.');
return;
}
showFileError('');
currentFile = file;
selectedNames.clear();
searchKeyword = '';
if (productSearch) productSearch.value = '';
clearResult();
resultInfo.innerHTML = '';
warningBox.style.display = 'none';
setLoading(true, 'CSV를 분석하는 중...');
try {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/mall-event/event-extract/analyze', { method: 'POST', body: form });
if (!res.ok) {
showFileError(await readError(res, 'CSV를 분석하지 못했습니다.'));
currentFile = null;
return;
}
const data = await res.json();
productItems = data.product_items || [];
fileInfo.style.display = 'block';
fileInfo.innerHTML =
'<div style="font-weight:600; word-break:break-all;">📄 ' + escapeHtml(file.name) + '</div>' +
'<div style="color:#718096;">인코딩: ' + escapeHtml(data.encoding) + ' · 헤더 ' + data.header_row + '행 · ' + data.column_count + '개 열</div>' +
'<div style="color:#718096;">데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
Number(data.total_orders).toLocaleString() + '건 · 상품 ' + productItems.length + '종</div>';
if (data.warnings && data.warnings.length) {
warningBox.style.display = 'block';
warningBox.innerHTML = data.warnings.map(function (w) {
return '⚠ ' + escapeHtml(w);
}).join('<br>');
}
renderProductList();
} catch (err) {
console.error(err);
showFileError('업로드 중 오류가 발생했습니다.');
currentFile = null;
} finally {
setLoading(false);
}
}
bindDropzone(dropzone, fileInput, pickBtn, handleFile);
if (resetBtn) resetBtn.addEventListener('click', resetAll);
// ---- 상품 목록 ----
function renderProductList() {
if (!productItems.length) {
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">K열(주문상품명)에서 상품을 찾지 못했습니다.</div>';
updateSelectedInfo();
return;
}
const visible = productItems.filter(function (item) {
return !searchKeyword || normalize(item.name).includes(searchKeyword);
});
if (!visible.length) {
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</div>';
updateSelectedInfo();
return;
}
productListBox.innerHTML = visible.map(function (item) {
const checked = selectedNames.has(item.name) ? ' checked' : '';
return '' +
'<label style="display:flex; align-items:center; gap:8px; padding:5px 8px; border-bottom:1px solid #edf2f7; cursor:pointer; font-size:0.85rem;">' +
'<input type="checkbox" class="ee-product-check" value="' + escapeHtml(item.name) + '"' + checked + ' style="width:15px; height:15px; cursor:pointer;">' +
'<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(item.name) + '</span>' +
'<span style="color:#718096; white-space:nowrap; font-size:0.78rem;">' + Number(item.row_count).toLocaleString() + '행</span>' +
'<span style="color:#3182ce; font-weight:600; white-space:nowrap;">' + Number(item.order_count).toLocaleString() + '건</span>' +
'</label>';
}).join('');
updateSelectedInfo();
}
function updateSelectedInfo() {
if (!selectedNames.size) {
selectedInfo.textContent = productItems.length ? '선택된 상품이 없습니다.' : '';
return;
}
const names = Array.from(selectedNames);
const preview = names.slice(0, 2).join(', ');
const more = names.length > 2 ? ' 외 ' + (names.length - 2) + '개' : '';
selectedInfo.innerHTML = '선택 <b style="color:#3182ce;">' + names.length + '개</b> — ' + escapeHtml(preview + more);
}
productListBox.addEventListener('change', function (e) {
const check = e.target.closest('.ee-product-check');
if (!check) return;
if (check.checked) selectedNames.add(check.value);
else selectedNames.delete(check.value);
updateSelectedInfo();
clearResult();
});
if (productSearch) {
productSearch.addEventListener('input', function () {
searchKeyword = normalize(productSearch.value);
renderProductList();
});
}
if (selectAllBtn) {
selectAllBtn.addEventListener('click', function () {
productItems.forEach(function (item) {
if (!searchKeyword || normalize(item.name).includes(searchKeyword)) selectedNames.add(item.name);
});
renderProductList();
clearResult();
});
}
if (selectNoneBtn) {
selectNoneBtn.addEventListener('click', function () {
selectedNames.clear();
renderProductList();
clearResult();
});
}
// ---- 추출 조건 라디오 ----
modeRadios.forEach(function (radio) {
radio.addEventListener('change', function () {
modeRadios.forEach(function (r) {
const box = r.closest('label');
if (!box) return;
const on = r.checked;
box.style.borderColor = on ? '#bee3f8' : '#e2e8f0';
box.style.background = on ? '#ebf8ff' : '#fff';
const title = box.querySelector('span > span');
if (title) title.style.color = on ? '#2b6cb0' : '#2d3748';
});
clearResult();
});
});
// ---- 조건 수집 ----
function buildFormData() {
if (!currentFile) {
alert('먼저 주문 CSV 파일을 업로드해주세요.');
return null;
}
if (!selectedNames.size) {
alert('추출할 상품을 최소 1개 선택해주세요.');
return null;
}
const form = new FormData();
form.append('file', currentFile);
form.append('selected_json', JSON.stringify(Array.from(selectedNames)));
form.append('mode', currentMode());
return form;
}
// ---- 다운로드 (중간 확인 단계 없이 바로 파일 생성) ----
downloadBtn.addEventListener('click', async function () {
if (downloadBtn.disabled) return;
const form = buildFormData();
if (!form) return;
const modeLabel = currentMode() === 'product' ? '해당상품만 추출' : '해당 주문 추출';
const suffix = currentMode() === 'product' ? '_행사추출_해당상품만' : '_행사추출_해당주문';
const fallback = ((currentFile && currentFile.name) || '주문').replace(/\.csv$/i, '') + suffix + '.xlsx';
warningBox.style.display = 'none';
warningBox.innerHTML = '';
clearResult();
setLoading(true, '추출 파일을 만드는 중...');
try {
const res = await fetch('/api/mall-event/event-extract/download', { method: 'POST', body: form });
if (!res.ok) {
alert(await readError(res, '엑셀 다운로드 중 오류가 발생했습니다.'));
return;
}
// 서버가 헤더로 실어 보낸 추출 결과를 화면에 남긴다
const rows = Number(res.headers.get('X-Extract-Rows') || 0);
const orders = Number(res.headers.get('X-Extract-Orders') || 0);
const matched = Number(res.headers.get('X-Extract-Matched-Rows') || 0);
const total = Number(res.headers.get('X-Extract-Total-Rows') || 0);
await saveBlob(res, fallback);
resultInfo.innerHTML =
'<div style="color:#2f855a; font-weight:600;">✓ 다운로드 완료</div>' +
'<div>조건: <b>' + modeLabel + '</b></div>' +
'<div>상품이 들어있는 행 <b>' + matched.toLocaleString() + '행</b></div>' +
'<div>추출 결과 <b style="color:#3182ce;">' + rows.toLocaleString() + '행</b>' +
' · 주문 <b style="color:#3182ce;">' + orders.toLocaleString() + '건</b></div>' +
'<div style="color:#718096; font-size:0.78rem;">전체 ' + total.toLocaleString() + '행 중</div>';
} catch (err) {
console.error(err);
alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
});
clearResult();
}
})();
+363
View File
@@ -0,0 +1,363 @@
// 쿠팡 밀크런 캘린더
//
// 화면이 열리면 구글 드라이브의 밀크런 출고리스트를 자동으로 읽어
// 시트 이름(YYYYMMDD)을 달력에 표시한다.
// 달력에서 날짜를 고르면 그 시트의 제품코드/제품명/수량을 읽어
// 붙여넣기 입력을 채우고 곧바로 분석까지 실행한다.
// (시트 주소는 서버에 설정되어 있어 화면에서 입력하지 않는다)
(function () {
'use strict';
const DOW = ['일', '월', '화', '수', '목', '금', '토'];
document.addEventListener('DOMContentLoaded', function () {
const refreshBtn = document.getElementById('btn-gsheet-refresh');
const messageBox = document.getElementById('gsheet-message');
const fileInfo = document.getElementById('gsheet-file-info');
const calendarBox = document.getElementById('gsheet-calendar');
const otherBox = document.getElementById('gsheet-other-sheets');
if (!calendarBox) return;
// 붙여넣기 입력 쪽 요소 (분석은 기존 로직을 그대로 재사용한다)
const pasteInput = document.getElementById('milkrun-input');
const analyzeBtn = document.getElementById('btn-milkrun-analyze');
let sheetsByDate = {}; // 'YYYY-MM-DD' -> 시트 이름
let otherSheets = []; // 날짜로 못 읽은 시트 이름
let viewYear = null;
let viewMonth = null; // 0-based
let selectedDate = null;
let busy = false;
function escapeHtml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
let messageTimer = null;
function showMessage(text, kind, autoHideMs) {
if (messageTimer) {
clearTimeout(messageTimer);
messageTimer = null;
}
if (!text) {
messageBox.style.display = 'none';
messageBox.innerHTML = '';
return;
}
const palette = {
error: { bg: '#fff5f5', border: '#feb2b2', color: '#c53030' },
info: { bg: '#ebf8ff', border: '#bee3f8', color: '#2b6cb0' },
success: { bg: '#f0fff4', border: '#9ae6b4', color: '#276749' },
// 눈에 잘 띄어야 하는 안내 (오늘 출고 없음 등)
alert: { bg: '#fffbeb', border: '#f6ad55', color: '#c05621' }
};
const c = palette[kind] || palette.info;
messageBox.style.display = 'block';
messageBox.style.background = c.bg;
messageBox.style.border = '2px solid ' + c.border;
messageBox.style.color = c.color;
messageBox.innerHTML = text;
if (autoHideMs) {
messageTimer = setTimeout(function () {
messageBox.style.display = 'none';
messageBox.innerHTML = '';
messageTimer = null;
}, autoHideMs);
}
}
function dateKeyOf(date) {
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0');
}
// 달력은 [본문 + 로딩 덮개] 두 겹으로 만들어 둔다.
// 본문만 다시 그리므로 덮개가 지워지지 않는다.
function ensureCalendarShell() {
if (calendarBox.dataset.ready === '1') return;
calendarBox.style.position = 'relative';
calendarBox.innerHTML =
'<div id="cal-body"></div>' +
'<div id="cal-overlay" style="display:none; position:absolute; inset:0; z-index:5;' +
' background:rgba(255,255,255,0.82); border-radius:4px;' +
' align-items:center; justify-content:center; gap:8px; flex-direction:column;">' +
'<div style="width:22px; height:22px; border:3px solid #cbd5e0; border-top-color:#3182ce;' +
' border-radius:50%; animation:milkrun-spin 0.8s linear infinite;"></div>' +
'<div id="cal-overlay-text" style="font-size:0.82rem; font-weight:600; color:#2d3748;">데이터 읽는 중...</div>' +
'</div>';
calendarBox.dataset.ready = '1';
}
function setBusy(isBusy, label) {
busy = isBusy;
if (refreshBtn) {
refreshBtn.disabled = isBusy;
refreshBtn.style.opacity = isBusy ? '0.6' : '1';
refreshBtn.textContent = isBusy ? '읽는 중...' : '새로고침';
}
ensureCalendarShell();
const overlay = document.getElementById('cal-overlay');
const overlayText = document.getElementById('cal-overlay-text');
const body = document.getElementById('cal-body');
if (overlay) overlay.style.display = isBusy ? 'flex' : 'none';
if (overlayText && label) overlayText.textContent = label;
if (body) {
// 읽는 동안에는 날짜를 누를 수 없게 한다
body.style.pointerEvents = isBusy ? 'none' : '';
body.style.opacity = isBusy ? '0.45' : '';
}
}
// ---- 시트 이름 → 날짜 ----
// '20260807(금)' 처럼 앞 8자리가 날짜인 이름을 달력에 올린다
function parseSheetDate(name) {
const m = String(name).match(/(\d{4})(\d{2})(\d{2})/);
if (!m) return null;
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
const date = new Date(y, mo - 1, d);
if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d) return null;
return { key: m[1] + '-' + m[2] + '-' + m[3], year: y, month: mo - 1, day: d };
}
function indexSheets(sheets) {
sheetsByDate = {};
otherSheets = [];
let latest = null;
sheets.forEach(function (sheet) {
const parsed = parseSheetDate(sheet.name);
if (!parsed) {
otherSheets.push(sheet.name);
return;
}
sheetsByDate[parsed.key] = sheet.name;
if (!latest || parsed.key > latest.key) latest = parsed;
});
return latest;
}
// ---- 달력 ----
function renderCalendar(placeholderText) {
calendarBox.style.display = 'block';
ensureCalendarShell();
const body = document.getElementById('cal-body');
if (viewYear === null) {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
}
const first = new Date(viewYear, viewMonth, 1);
const startDow = first.getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const monthPrefix = viewYear + '-' + String(viewMonth + 1).padStart(2, '0');
const monthCount = Object.keys(sheetsByDate).filter(function (key) {
return key.indexOf(monthPrefix) === 0;
}).length;
let html = '' +
'<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:6px;">' +
'<button type="button" class="cal-nav" data-move="-1" style="border:1px solid #e2e8f0; background:#fff; border-radius:4px; cursor:pointer; padding:2px 8px; font-size:0.8rem;">◀</button>' +
'<div style="font-size:0.88rem; font-weight:600; color:#2d3748;">' + viewYear + '년 ' + (viewMonth + 1) + '월' +
'<span style="color:#a0aec0; font-weight:400; font-size:0.75rem;"> · 출고 ' + monthCount + '일</span></div>' +
'<button type="button" class="cal-nav" data-move="1" style="border:1px solid #e2e8f0; background:#fff; border-radius:4px; cursor:pointer; padding:2px 8px; font-size:0.8rem;">▶</button>' +
'</div>' +
'<div style="display:grid; grid-template-columns:repeat(7,1fr); gap:2px;">';
DOW.forEach(function (label, index) {
const color = index === 0 ? '#e53e3e' : (index === 6 ? '#3182ce' : '#718096');
html += '<div style="text-align:center; font-size:0.7rem; color:' + color + '; padding:2px 0;">' + label + '</div>';
});
for (let i = 0; i < startDow; i++) html += '<div></div>';
const todayKey = dateKeyOf(new Date());
for (let day = 1; day <= daysInMonth; day++) {
const key = monthPrefix + '-' + String(day).padStart(2, '0');
const sheetName = sheetsByDate[key];
const isSelected = selectedDate === key;
let style = 'text-align:center; padding:5px 0; border-radius:4px; font-size:0.78rem;';
// 오늘 날짜는 테두리로 표시해 기준점을 알 수 있게 한다
if (key === todayKey) style += ' outline:2px solid #ed8936; outline-offset:-2px;';
let attrs = '';
if (sheetName) {
attrs = ' class="cal-day" data-date="' + key + '" title="' + escapeHtml(sheetName) + '"';
style += isSelected
? ' background:#2b6cb0; color:#fff; font-weight:700; cursor:pointer;'
: ' background:#bee3f8; color:#2b6cb0; font-weight:600; cursor:pointer;';
} else {
style += ' color:#cbd5e0;';
}
html += '<div' + attrs + ' style="' + style + '">' + day + '</div>';
}
html += '</div>';
if (placeholderText) {
html += '<div style="text-align:center; color:#a0aec0; font-size:0.75rem; padding:6px 0 2px;">' +
escapeHtml(placeholderText) + '</div>';
}
body.innerHTML = html;
body.querySelectorAll('.cal-nav').forEach(function (btn) {
btn.addEventListener('click', function () {
const date = new Date(viewYear, viewMonth + Number(btn.dataset.move), 1);
viewYear = date.getFullYear();
viewMonth = date.getMonth();
renderCalendar();
});
});
body.querySelectorAll('.cal-day').forEach(function (cell) {
cell.addEventListener('click', function () {
if (busy) return;
selectDate(cell.dataset.date);
});
});
}
function renderOtherSheets() {
if (!otherSheets.length) {
otherBox.style.display = 'none';
otherBox.innerHTML = '';
return;
}
otherBox.style.display = 'block';
otherBox.innerHTML = '날짜로 읽지 못한 시트 ' + otherSheets.length + '개: ' +
otherSheets.slice(0, 5).map(escapeHtml).join(', ') +
(otherSheets.length > 5 ? ' 외' : '');
}
// ---- 시트 목록 불러오기 ----
async function loadSheetTabs(forceRefresh) {
selectedDate = null;
showMessage('구글 시트에서 출고일을 읽는 중...', 'info');
setBusy(true, '출고일을 읽는 중...');
try {
const res = await fetch('/api/coupang-milkrun/sheet-tabs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: !!forceRefresh })
});
if (!res.ok) {
let detail = '출고일을 불러오지 못했습니다. (서버 응답 ' + res.status + ')';
try {
const data = await res.json();
if (data && data.detail) detail = data.detail;
} catch (e) { /* 본문이 JSON이 아니면 기본 메시지 */ }
showMessage(escapeHtml(detail), 'error');
renderCalendar('출고일을 불러오지 못했습니다');
return;
}
const data = await res.json();
const sheets = data.sheets || [];
indexSheets(sheets);
fileInfo.style.display = 'block';
fileInfo.innerHTML =
'<b>' + escapeHtml(data.file_name || '쿠팡 밀크런 출고리스트') + '</b>' +
' <span style="color:#a0aec0;">· 출고일 ' + Object.keys(sheetsByDate).length + '일' +
(data.cached ? ' · 최근 결과' : '') + '</span>';
// 항상 오늘 날짜를 기준으로 본다
const today = new Date();
const todayKey = dateKeyOf(today);
viewYear = today.getFullYear();
viewMonth = today.getMonth();
renderCalendar();
renderOtherSheets();
if (sheetsByDate[todayKey]) {
// 오늘 출고가 있으면 바로 읽어서 분석까지 진행 (메시지는 selectDate가 남긴다)
await selectDate(todayKey);
} else {
showMessage(
'<span style="font-size:0.95rem; font-weight:700;">오늘 출고할 밀크런은 없습니다.</span>',
'alert', 2000);
}
} catch (err) {
console.error(err);
showMessage('출고일 조회 중 오류가 발생했습니다.', 'error');
renderCalendar('출고일을 불러오지 못했습니다');
} finally {
setBusy(false);
}
}
// ---- 날짜 선택 → 값 읽기 → 분석 ----
async function selectDate(dateKey) {
const sheetName = sheetsByDate[dateKey];
if (!sheetName) return;
selectedDate = dateKey;
renderCalendar();
showMessage('<b>' + escapeHtml(sheetName) + '</b> 출고리스트를 읽는 중...', 'info');
setBusy(true, '출고리스트를 읽는 중...');
try {
const res = await fetch('/api/coupang-milkrun/sheet-rows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sheet: sheetName })
});
if (!res.ok) {
let detail = '출고리스트를 읽지 못했습니다. (서버 응답 ' + res.status + ')';
try {
const data = await res.json();
if (data && data.detail) detail = data.detail;
} catch (e) { /* 무시 */ }
showMessage(escapeHtml(detail), 'error');
return;
}
const data = await res.json();
const items = data.items || [];
if (!items.length) {
showMessage('<b>' + escapeHtml(sheetName) + '</b>에서 제품코드/수량을 찾지 못했습니다. ' +
'시트 형식을 확인해주세요.', 'error');
return;
}
if (pasteInput) pasteInput.value = data.text || '';
if (analyzeBtn) analyzeBtn.click(); // 기존 분석 로직 재사용
const skipped = data.skipped
? ' <span style="color:#975a16;">(건너뛴 줄 ' + data.skipped + '개)</span>' : '';
showMessage('<b>' + escapeHtml(sheetName) + '</b> · ' + items.length +
'줄을 불러와 분석했습니다.' + skipped, 'success');
} catch (err) {
console.error(err);
showMessage('출고리스트를 읽는 중 오류가 발생했습니다.', 'error');
} finally {
setBusy(false);
}
}
// ---- 시작 ----
if (refreshBtn) refreshBtn.addEventListener('click', function () { loadSheetTabs(true); });
// 밀크런 탭을 누를 때마다 구글 시트를 다시 읽는다 (시트가 추가/수정됐을 수 있으므로)
const milkrunMenu = document.querySelector('[data-tab="milkrun-tab"]');
if (milkrunMenu) {
milkrunMenu.addEventListener('click', function () {
if (!busy) loadSheetTabs(true);
});
}
// 달력 틀을 먼저 보여주고(덮개가 '읽는 중'을 표시), 출고일은 뒤이어 채운다
renderCalendar();
loadSheetTabs(false);
});
})();
+4158 -995
View File
File diff suppressed because it is too large Load Diff
+105 -2
View File
@@ -1,4 +1,107 @@
[ [
{
"version": "v9.0",
"date": "2026.08.25",
"sections": [
{
"items": [
"New CS 작업 탭과 새로운 주문 처리 UI 추가: 상품 분류별 하위 상품 선택, 선택 상품 삭제 및 수량 변경, 주문금액·할인·배송비·입금금액 실시간 계산, 문자 미리보기 기능을 한 화면에서 사용할 수 있도록 개선"
],
"type": "added"
},
{
"items": [
"기존 CS 작업 탭은 전체 상품 그룹을 한 번에 확인할 수 있는 이전 UI로 유지하여 New CS 작업 탭과 선택적으로 사용할 수 있도록 구성"
],
"type": "modified"
}
]
},
{
"version": "v8.82",
"date": "2026.08.25",
"sections": [
{
"items": [
"주문 정보 프로그램에서 복사된 재발송 정보를 자동으로 입력 하는 기능 추가"
],
"type": "added"
}
]
},
{
"version": "v8.81",
"date": "2026.08.12",
"sections": [
{
"items": [
"자사몰 행사 메뉴에 \"행사주문 추출\" 기능 추가 (아롱님 전용 메뉴): 카페24 주문 CSV를 파일 선택 또는 드래그 앤 드롭으로 업로드하면 주문상품명(K열)의 첫 \" -\" 앞부분을 중복 없이 가나다순으로 정리해 보여주고, 여기서 고른 상품을 기준으로 필요한 행만 남긴 엑셀을 곧바로 내려받을 수 있음",
"추출 조건 2가지 제공: \"해당상품만 추출\"은 선택한 상품이 들어있는 행만 남기고, \"해당 주문 추출\"은 그 행의 주문번호(B열)와 같은 주문의 모든 행을 남김"
],
"type": "added"
}
]
},
{
"version": "v8.8",
"date": "2026.08.11",
"sections": [
{
"items": [
"자사몰 행사 메뉴 추가: 카페24에서 받은 발주주문서(엑셀)를 업로드해 행사 조건을 적용하고, 가공된 발주 파일을 다시 내려받는 공간 (발주서 가공 기능이 앞으로 계속 추가될 예정)",
"선착순 사은품 지급 기능 추가: 발주서를 파일 선택 또는 드래그 앤 드롭으로 업로드하면 주문목록(Q열)의 상품을 중복 없이 가나다순으로 정리해 보여주고, 선택한 상품을 주문한 고객을 주문날짜 순서대로 선착순 N명까지 자동 선별 (인원은 10/30/50/100 버튼 또는 직접 입력)",
"선별된 각 주문의 마지막 행 바로 아래에 사은품 행을 자동 삽입 (품목 순번 자동 증가, 사은품 아이템코드·이름·수량 입력, 주문목록에 \"선착순 사은품\" 표기, 삽입된 행의 A~Q열 노란색 표시)",
"적용 결과를 미리 확인한 뒤 전체 발주 파일(업로드한 파일과 같은 이름)과 사은품이 적용된 주문만 담은 파일을 각각 엑셀로 다운로드 가능"
],
"type": "added"
},
{
"items": [
"코드표: 단품 코드와 세트 코드 제목 옆에 이름 검색란 추가 (입력하는 즉시 실시간 검색, 초기화 버튼과 Esc 키로 검색어 해제)",
"왼쪽 메뉴에 아이콘 추가 (🎧 CS 작업, ✍️ 수동발주, 📦 상품설정, 🤖 자동발주, 🏷️ 코드표, ↩️ 반품관리, 🎁 자사몰 행사, 🚚 밀크런, ⚙️ 설정, 🚀 History)"
],
"type": "modified"
}
]
},
{
"version": "v8.7",
"date": "2026.08.07",
"sections": [
{
"items": [
"쿠팡 밀크런 메뉴 추가: 밀크런 발주서(제품코드/제품명/수량)를 붙여넣으면 코드표 DB의 세트-단품 구성을 기준으로 낱개 코드별 합계 수량을 자동 계산",
"쿠팡 밀크런: 사방넷 코드, 낱개 코드, 상품 이름, 합계 수량 순으로 결과 표시, 계산 결과를 사방넷 업로드 양식(상품코드/가용수량/불용수량/바코드) 그대로 엑셀 다운로드 가능"
],
"type": "added"
}
]
},
{
"version": "v8.61",
"date": "2026.06.18",
"sections": [
{
"items": [
"세트 수정 및 추가 저장 시 구성품 정보 저장 로직의 JavaScript 오류 수정 (저장이 완료되지 않던 문제 해결)"
],
"type": "modified"
}
]
},
{
"version": "v8.6",
"date": "2026.06.17",
"sections": [
{
"items": [
"코드표에서 세트코드 수정 시 구성품 드래그 앤 드롭 이동(정렬) 기능 추가",
"최근 전송 메시지 화면에서 이름, 연락처, 주소가 입력된 메시지는 메시지 맨 오른쪽에 *표시 추가"
],
"type": "modified"
}
]
},
{ {
"version": "v8.59", "version": "v8.59",
"date": "2026.06.15", "date": "2026.06.15",
@@ -26,7 +129,7 @@
}, },
{ {
"version": "v8.57", "version": "v8.57",
"date": "2026.05.26", "date": "2026.05.28",
"sections": [ "sections": [
{ {
"items": [ "items": [
@@ -295,4 +398,4 @@
} }
] ]
} }
] ]