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
+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}