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:
@@ -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}
|
||||
Reference in New Issue
Block a user