init: dbx-corm 프로젝트 Docker 기반 마이그레이션

주요 변경사항:
- DB 접속정보를 환경변수(.env) 방식으로 분리
- orderlist_app → orderlist_db 변경 (main.py, routers/returns.py)
- 네이버/카페24 API 키도 환경변수로 분리
- Dockerfile / docker-compose.yml 작성 (외부 PostgreSQL 네트워크 연결)
- .gitignore: 민감파일(.env, cafe24_tokens.json, manual-ordering.json) 제외
- 불필요 파일 정리 (venv, __pycache__, 일회성 스크립트, xlsx 등)
- README.md / .env.example / push_to_gitea.bat 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:11:34 +09:00
commit 6e438344d0
20 changed files with 8179 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
__pycache__/
*.pyc
*.pyo
venv/
env/
.venv/
.git/
.gitignore
.dockerignore
.vscode/
.idea/
.antigravitycli/
.pytest_cache/
build/
dist/
# 환경변수는 env_file로 전달
.env
# 민감정보는 별도 마운트
# (cafe24_tokens.json, manual-ordering.json은 컨테이너 안에는 있어야 하므로 제외 안 함)
# 로컬 데이터
*.db
*.dat
sync.ffs_db
*.zip
*.log
# 문서/스크립트
README.md
docker-compose.yml
docker-compose*.yml
push_to_gitea.bat
deploy.bat
deploy.ps1
start.bat
test.ps1
*.xlsx
+33
View File
@@ -0,0 +1,33 @@
# ============================================
# PostgreSQL 접속 정보
# ============================================
# 서버에서 docker network 확인 후 host 값 수정
# docker ps --filter "ancestor=postgres" --format "{{.Names}}"
POSTGRES_HOST=postgres-db
POSTGRES_PORT=5432
POSTGRES_USER=king
POSTGRES_PASSWORD=여기에_비밀번호_입력
# 데이터베이스 이름 (3개 분리되어 있음)
ITEMCODE_DB=itemcode_db
ORDERLIST_DB=orderlist_db
RETURN_DB=return_db
# ============================================
# 네이버 커머스 API
# ============================================
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# ============================================
# 카페24 API
# ============================================
CAFE24_CLIENT_ID=
CAFE24_CLIENT_SECRET=
CAFE24_MALL_ID=miraskitchen
CAFE24_REDIRECT_URI=
# ============================================
# 앱 설정
# ============================================
APP_PORT=8002
+46
View File
@@ -0,0 +1,46 @@
# 환경변수
.env
.env.*
!.env.example
# 민감정보 (절대 커밋 금지)
cafe24_tokens.json
manual-ordering.json
*.pem
*.key
*credential*
*secret*
*token*
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
venv/
env/
.venv/
.pytest_cache/
*.egg-info/
build/
dist/
# 로컬 데이터
*.db
*.dat
sync.ffs_db
*.zip
*.log
# IDE / OS
.vscode/
.idea/
.antigravitycli/
.DS_Store
Thumbs.db
# 배포 임시 (push_to_gitea.bat은 공유 가능하므로 제외 안 함)
deploy.bat
deploy.ps1
start.bat
test.ps1
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.10-slim
WORKDIR /app
# psycopg2 런타임 라이브러리
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# 의존성 먼저 (캐시 활용)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 소스 복사
COPY . .
EXPOSE 8002
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002", "--proxy-headers", "--forwarded-allow-ips=*"]
+146
View File
@@ -0,0 +1,146 @@
# DBX CORM (CS Web Program)
CS / 발주 / 반품 통합 관리 웹 애플리케이션.
FastAPI + PostgreSQL 기반, Docker로 실행.
---
## 기술 스택
- **Backend**: Python 3.10, FastAPI, Uvicorn
- **DB**: PostgreSQL (외부 Docker 컨테이너 연결)
- **외부 연동**: 네이버 커머스 API, 카페24 API, Google Sheets
- **Frontend**: Jinja2 템플릿 + Vanilla JS
---
## 사용 DB
| DB 이름 | 용도 |
|---|---|
| `itemcode_db` | 품목코드 마스터 |
| `orderlist_db` | 주문 정보 조회 |
| `return_db` | 반품 데이터 저장 |
모두 DB 계정은 `king` 통일.
---
## 서버 배포 절차 (`/opt/dbx-corm`)
### 1. 코드 받기
```bash
sudo mkdir -p /opt/dbx-corm
sudo chown $USER:$USER /opt/dbx-corm
cd /opt
git clone https://gitea.no1king.freeddns.org/king/dbx-corm.git
cd /opt/dbx-corm
```
### 2. 환경변수 설정
```bash
cp .env.example .env
nano .env
```
`.env` 파일에 다음을 채워주세요:
- `POSTGRES_PASSWORD` (DB 비밀번호)
- `POSTGRES_HOST` (PostgreSQL 컨테이너 이름. 아래로 확인)
- `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET`
- `CAFE24_CLIENT_ID`, `CAFE24_CLIENT_SECRET`, `CAFE24_REDIRECT_URI`
PostgreSQL 컨테이너 이름 확인:
```bash
docker ps --filter "ancestor=postgres" --format "{{.Names}}"
```
### 3. PostgreSQL 네트워크 확인 / 수정
```bash
docker network ls | grep postgres
```
`docker-compose.yml``networks.postgres-net.name` 값을 실제 네트워크 이름으로 수정.
### 4. 민감 파일 업로드 (Git에 없음)
로컬 PC에서 서버로 직접 복사:
```bash
# 로컬 PC에서 실행
scp cafe24_tokens.json king@<서버>:/opt/dbx-corm/
scp manual-ordering.json king@<서버>:/opt/dbx-corm/
```
### 5. 빌드 및 실행
```bash
cd /opt/dbx-corm
docker compose up -d --build
```
### 6. 상태 확인
```bash
docker compose ps
docker compose logs -f
```
---
## 업데이트 (Git pull 후 재배포)
```bash
cd /opt/dbx-corm
git pull
docker compose up -d --build
```
---
## 접속
- 컨테이너 포트: `8002`
- 호스트 포트: `8002`
- 헬스체크: `http://<서버>:8002/`
Nginx Proxy Manager 등을 사용한다면 `<서버>:8002`로 프록시 설정.
---
## 로컬 개발 (Windows)
```cmd
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
copy .env.example .env
:: .env 편집
python main.py
```
---
## Gitea 푸시 (Windows)
리포지토리에 변경사항을 올릴 때:
```cmd
push_to_gitea.bat
```
스크립트가 변경사항 확인 → 커밋 메시지 입력 → push 자동 수행.
---
## 주의사항
- `.env`, `cafe24_tokens.json`, `manual-ordering.json`**민감 파일은 절대 Git에 올리지 마세요**.
- `.gitignore`에 자동 제외 설정되어 있습니다.
- DB 이름은 반드시 `orderlist_db`를 사용 (예전 이름 `orderlist_app` 사용 금지).
+242
View File
@@ -0,0 +1,242 @@
import requests
import json
import os
import time
from datetime import datetime, timedelta
# 환경변수에서 카페24 인증 정보 로드
CLIENT_ID = os.getenv("CAFE24_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET", "")
MALL_ID = os.getenv("CAFE24_MALL_ID", "miraskitchen")
REDIRECT_URI = os.getenv("CAFE24_REDIRECT_URI", "")
TOKEN_FILE = "cafe24_tokens.json"
def get_auth_url():
# state parameter could be added for security but keeping it simple for local app
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/authorize"
url += f"?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}"
url += f"&scope=mall.read_order,mall.write_order,mall.read_product"
return url
import base64
def _get_basic_auth_header():
credentials = f"{CLIENT_ID}:{CLIENT_SECRET}"
encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
return f"Basic {encoded}"
def request_new_token(auth_code: str):
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token"
headers = {
"Authorization": _get_basic_auth_header(),
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI
}
response = requests.post(url, headers=headers, data=data)
if response.status_code == 200:
_save_tokens(response.json())
return True
else:
raise Exception(f"Failed to get token: {response.text}")
def refresh_access_token(refresh_token: str):
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token"
headers = {
"Authorization": _get_basic_auth_header(),
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token
}
response = requests.post(url, headers=headers, data=data)
if response.status_code == 200:
_save_tokens(response.json())
return True
else:
# Refresh token might be expired. Need to re-authenticate.
if os.path.exists(TOKEN_FILE):
os.remove(TOKEN_FILE)
raise Exception("Refresh token expired or invalid. Please re-authenticate.")
def _save_tokens(token_data):
# Cafe24 returns expires_at in string format like "2023-10-01T12:00:00.000"
if 'expires_at' not in token_data:
# Fallback if expires_at is not provided (Cafe24 provides expires_at)
expires_in = token_data.get('expires_in', 7200) # usually 2 hours
expires_at = datetime.now() + timedelta(seconds=expires_in - 60) # 1 min buffer
token_data['expires_at'] = expires_at.isoformat()
with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
json.dump(token_data, f)
def get_valid_access_token():
if not os.path.exists(TOKEN_FILE):
raise Exception("No tokens found. Please authenticate first.")
with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
tokens = json.load(f)
expires_at_str = tokens.get('expires_at')
# Parse Cafe24 typical datetime format or isoformat
try:
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...")
refresh_access_token(tokens.get('refresh_token'))
return get_valid_access_token()
return tokens.get('access_token')
def get_cafe24_orders_count(status="N20"):
token = get_valid_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": "2026-03-01"
}
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders/count"
params = {
"start_date": start_date,
"end_date": end_date,
"order_status": status
}
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
return response.json().get('count', 0)
return 0
def get_cafe24_orders(status="N20", progress_callback=None):
"""
Fetch orders with specific status (default N20: 배송준비중)
"""
token = get_valid_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": "2026-03-01"
}
# Calculate search date range (e.g. last 7 days)
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders"
all_orders = []
limit = 100
offset = 0
while True:
params = {
"start_date": start_date,
"end_date": end_date,
"order_status": status,
"limit": limit,
"offset": offset,
"embed": "receivers,items" # embed items and receiver addresses
}
response = requests.get(url, headers=headers, params=params)
if response.status_code != 200:
raise Exception(f"Failed to fetch Cafe24 orders: {response.text}")
json_data = response.json()
orders = json_data.get('orders', [])
all_orders.extend(orders)
if progress_callback:
progress_callback(len(all_orders))
if len(orders) < limit:
break
offset += limit
time.sleep(0.5) # rate limit prevention
return all_orders
def update_cafe24_tracking(dispatch_list):
"""
dispatch_list is a list of dict: {"order_id": "...", "tracking_no": "...", "shipping_company_code": "0004"}
Cafe24 API allows updating order shipments per order or item.
Assuming we update the whole order (not item by item): PUT /api/v2/admin/orders/{order_id}/shipments
"""
token = get_valid_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": "2026-03-01"
}
results = {
"success": [],
"fail": []
}
for item in dispatch_list:
order_id = item.get("order_id")
tracking_no = item.get("tracking_no")
company_code = item.get("shipping_company_code")
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders/{order_id}/shipments"
payload = {
"request": {
"tracking_no": tracking_no,
"shipping_company_code": company_code
}
}
response = requests.put(url, headers=headers, json=payload)
if response.status_code in [200, 201]:
results["success"].append(order_id)
else:
reason = "Unknown Error"
try:
err_data = response.json()
reason = err_data.get('error', {}).get('message', response.text)
except:
reason = response.text
results["fail"].append({
"order_id": order_id,
"reason": reason
})
time.sleep(0.3) # API rate limit
return results
def get_product_details(product_no):
""" Fetch product details to get custom product codes/seller codes """
token = get_valid_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": "2026-03-01"
}
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/products/{product_no}?embed=variants"
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json().get('product', {})
return {}
+22
View File
@@ -0,0 +1,22 @@
services:
corm-app:
build: .
image: dbx-corm:latest
container_name: dbx-corm
restart: unless-stopped
ports:
- "8002:8002"
env_file:
- .env
volumes:
# 민감 파일은 Git에 안 올리고 서버 호스트에서 직접 마운트
- ./cafe24_tokens.json:/app/cafe24_tokens.json
- ./manual-ordering.json:/app/manual-ordering.json
# 토큰 파일 자동 갱신 결과를 호스트에 영구 보존
networks:
- postgres-net
networks:
postgres-net:
external: true
name: postgres_default # 서버의 실제 PostgreSQL 네트워크 이름으로 수정
+1598
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
import os
import requests
import time
import bcrypt
import base64
CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "")
def get_access_token():
timestamp = str(int(time.time() * 1000))
pwd = f"{CLIENT_ID}_{timestamp}"
hashed = bcrypt.hashpw(pwd.encode('utf-8'), CLIENT_SECRET.encode('utf-8'))
client_secret_sign = base64.urlsafe_b64encode(hashed).decode('utf-8')
url = "https://api.commerce.naver.com/external/v1/oauth2/token"
payload = {
"client_id": CLIENT_ID,
"timestamp": timestamp,
"client_secret_sign": client_secret_sign,
"grant_type": "client_credentials",
"type": "SELF"
}
# URL encoded post
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
response = requests.post(url, data=payload, headers=headers)
if response.status_code == 200:
return response.json().get('access_token')
else:
raise Exception(f"Failed to get token: {response.text}")
def get_payment_done_orders(token):
url = "https://api.commerce.naver.com/external/v1/pay-order/seller/product-orders/last-changed-statuses"
headers = {
"Authorization": f"Bearer {token}"
}
all_statuses = []
# 네이버 API는 lastChangedFrom과 lastChangedTo의 간격이 최대 24시간입니다.
# 따라서 최근 7일의 데이터를 가져오기 위해 24시간 단위로 반복 조회합니다.
now = time.time()
for days_ago in range(7, 0, -1):
start_time = now - (days_ago * 24 * 3600)
end_time = now - ((days_ago - 1) * 24 * 3600)
if days_ago == 1:
end_time = now
from_date = time.strftime('%Y-%m-%dT%H:%M:%S.000+09:00', time.localtime(start_time))
to_date = time.strftime('%Y-%m-%dT%H:%M:%S.000+09:00', time.localtime(end_time))
params = {
"lastChangedFrom": from_date,
"lastChangedTo": to_date,
"lastChangedType": "PAYED"
}
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
data = response.json()
statuses = data.get('data', {}).get('lastChangeStatuses', [])
all_statuses.extend(statuses)
else:
raise Exception(f"Failed to fetch orders ({from_date}): {response.text}")
return all_statuses
def get_product_order_details(token, product_order_ids):
if not product_order_ids:
return []
url = "https://api.commerce.naver.com/external/v1/pay-order/seller/product-orders/query"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
payload = {
"productOrderIds": product_order_ids
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
return response.json().get('data', [])
else:
raise Exception(f"Failed to fetch order details: {response.text}")
def dispatch_orders(token, dispatch_list):
"""
dispatch_list 형태:
[
{
"productOrderId": "...",
"deliveryMethod": "DELIVERY",
"deliveryCompanyCode": "CJGLS",
"trackingNumber": "..."
}
]
"""
url = "https://api.commerce.naver.com/external/v1/pay-order/seller/product-orders/dispatch"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
# 발송일은 현재 시간으로 셋팅
dispatch_date = time.strftime('%Y-%m-%dT%H:%M:%S+09:00')
for item in dispatch_list:
item["dispatchDate"] = dispatch_date
payload = {
"dispatchProductOrders": dispatch_list
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to dispatch orders: {response.text}")
def confirm_orders(token, product_order_ids):
"""주문건을 발주확인(배송준비중) 상태로 변경합니다."""
if not product_order_ids:
return {}
url = "https://api.commerce.naver.com/external/v1/pay-order/seller/product-orders/confirm"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
payload = {
"productOrderIds": product_order_ids
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to confirm orders: {response.text}")
def get_product_details(token, product_id):
"""지정된 상품의 전체 상세 정보(옵션 설정 포함)를 조회합니다."""
url = f"https://api.commerce.naver.com/external/v2/products/channel-products/{product_id}"
headers = {
"Authorization": f"Bearer {token}"
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
return {}
+101
View File
@@ -0,0 +1,101 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo.
echo =========================================
echo DBX CORM - Gitea Push
echo https://gitea.no1king.freeddns.org/king/dbx-corm
echo =========================================
echo.
REM 민감 파일 추적 확인
echo [1/5] Checking for sensitive files...
set FOUND=0
for %%F in (
.env
cafe24_tokens.json
manual-ordering.json
) do (
git ls-files --error-unmatch "%%F" >nul 2>&1
if not errorlevel 1 (
echo [ERROR] Sensitive file is tracked by git: %%F
set FOUND=1
)
)
if %FOUND%==1 (
echo.
echo PUSH BLOCKED. Remove with:
echo git rm --cached ^<filename^>
echo git commit -m "remove sensitive file"
echo.
pause
exit /b 1
)
echo OK.
echo.
REM Git 상태
echo [2/5] Git status:
echo -----------------------------------------
git status -s
echo -----------------------------------------
echo.
REM 변경사항 없으면 종료
git diff --quiet
set DIFF1=%errorlevel%
git diff --cached --quiet
set DIFF2=%errorlevel%
git ls-files --others --exclude-standard | findstr /r "." >nul
set DIFF3=%errorlevel%
if "%DIFF1%"=="0" if "%DIFF2%"=="0" if not "%DIFF3%"=="0" (
echo No changes to commit. Exiting.
pause
exit /b 0
)
REM 스테이징
echo [3/5] Staging files...
git add -A
echo OK.
echo.
REM 커밋 메시지 입력
echo [4/5] Enter commit message:
set /p MSG=^>^>
if "%MSG%"=="" (
echo [ERROR] Empty commit message.
pause
exit /b 1
)
git commit -m "%MSG%"
if errorlevel 1 (
echo [ERROR] Commit failed.
pause
exit /b 1
)
echo.
REM Push
echo [5/5] Pushing to Gitea...
git push -u origin master
if errorlevel 1 (
git push -u origin main
)
if %errorlevel%==0 (
echo.
echo =========================================
echo [OK] Push complete!
echo https://gitea.no1king.freeddns.org/king/dbx-corm
echo =========================================
) else (
echo.
echo [ERROR] Push failed. Check credentials or network.
)
echo.
pause
+14
View File
@@ -0,0 +1,14 @@
fastapi
uvicorn
pydantic
gspread
google-auth
jinja2
python-multipart
pandas
openpyxl
xlrd
bcrypt
requests
psycopg2-binary
python-dotenv
View File
+276
View File
@@ -0,0 +1,276 @@
import os
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
import psycopg2
import psycopg2.extras
import datetime
router = APIRouter(prefix="/api/return", tags=["Returns"])
# 환경변수 기반 DB 설정
_PG_HOST = os.getenv('POSTGRES_HOST', 'postgres-db')
_PG_PORT = int(os.getenv('POSTGRES_PORT', '5432'))
_PG_USER = os.getenv('POSTGRES_USER', 'king')
_PG_PASSWORD = os.getenv('POSTGRES_PASSWORD', '')
RETURN_DB_CONFIG = {
'host': _PG_HOST,
'port': _PG_PORT,
'dbname': os.getenv('RETURN_DB', 'return_db'),
'user': _PG_USER,
'password': _PG_PASSWORD,
}
def get_return_db_conn():
return psycopg2.connect(**RETURN_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
ORDER_DB_CONFIG = {
'host': _PG_HOST,
'port': _PG_PORT,
'dbname': os.getenv('ORDERLIST_DB', 'orderlist_db'),
'user': _PG_USER,
'password': _PG_PASSWORD,
}
def get_order_db_conn():
return psycopg2.connect(**ORDER_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
class ReturnRequest(BaseModel):
id: Optional[int] = None
request_date: str
request_type: str
receiver_name: str
address: str
phone: str
tracking_no: str
mall: str
remarks: str
class ReturnReceive(BaseModel):
id: Optional[int] = None
receive_date: str
tracking_no: str
mall: str
receiver_name: str
phone: str
address: Optional[str] = ''
remarks: str
receive_status: str
class BulkReturnReceive(BaseModel):
tracking_numbers: List[str]
@router.get("/lookup")
async def return_lookup(tracking_no: str):
try:
conn = get_order_db_conn()
cur = conn.cursor()
# 주문일시(order_date 또는 upload_date) 기준 가장 최신 1건 조회
cur.execute("""
SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address
FROM orders
WHERE tracking_number = %s
ORDER BY order_date DESC LIMIT 1
""", (tracking_no,))
row = cur.fetchone()
conn.close()
if row:
return {"success": True, "data": dict(row)}
return {"success": False, "message": "송장번호를 찾을 수 없습니다."}
except Exception as e:
print(f"Lookup error: {e}")
return {"success": False, "message": str(e)}
@router.post("/request")
async def create_return_request(req: ReturnRequest):
try:
conn = get_return_db_conn()
cur = conn.cursor()
if req.id:
cur.execute("""
UPDATE return_requests
SET request_date=%s, request_type=%s, receiver_name=%s, address=%s, phone=%s, tracking_no=%s, mall=%s, remarks=%s
WHERE id=%s
""", (req.request_date, req.request_type, req.receiver_name, req.address, req.phone, req.tracking_no, req.mall, req.remarks, req.id))
else:
cur.execute("""
INSERT INTO return_requests
(request_date, request_type, receiver_name, address, phone, tracking_no, mall, remarks)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (req.request_date, req.request_type, req.receiver_name, req.address, req.phone, req.tracking_no, req.mall, req.remarks))
conn.commit()
conn.close()
return {"success": True}
except Exception as e:
print(f"Request error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/request")
async def get_return_requests(tracking_no: Optional[str] = None):
try:
conn = get_return_db_conn()
cur = conn.cursor()
if tracking_no:
cur.execute("SELECT * FROM return_requests WHERE tracking_no = %s ORDER BY created_at DESC LIMIT 1", (tracking_no,))
else:
cur.execute("SELECT * FROM return_requests ORDER BY id DESC LIMIT 50")
rows = cur.fetchall()
conn.close()
return {"success": True, "data": [dict(r) for r in rows]}
except Exception as e:
return {"success": False, "message": str(e)}
@router.post("/receive/bulk")
async def create_return_receive_bulk(req: BulkReturnReceive):
try:
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
conn_return = get_return_db_conn()
cur_return = conn_return.cursor()
conn_order = get_order_db_conn()
cur_order = conn_order.cursor()
success_count = 0
for tracking_no in req.tracking_numbers:
tracking_no = tracking_no.strip()
if not tracking_no:
continue
mall = ""
receiver_name = ""
phone = ""
address = ""
# 1. Check return_requests
cur_return.execute("SELECT mall, receiver_name, phone, address, request_type, remarks FROM return_requests WHERE tracking_no = %s", (tracking_no,))
req_row = cur_return.fetchone()
if req_row:
mall = req_row['mall'] or ""
receiver_name = req_row['receiver_name'] or ""
phone = req_row['phone'] or ""
address = req_row['address'] or ""
req_type = req_row['request_type'] or ""
req_remarks = req_row['remarks'] or ""
if req_remarks:
remarks = f"{req_type}/{req_remarks}"
else:
remarks = req_type
else:
remarks = "고객이 반품"
# 2. Check orders
cur_order.execute("""
SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address
FROM orders
WHERE tracking_number = %s
ORDER BY order_date DESC LIMIT 1
""", (tracking_no,))
ord_row = cur_order.fetchone()
if ord_row:
mall = ord_row['vendor'] or ""
receiver_name = ord_row['recipient_name'] or ""
phone = ord_row['recipient_phone'] or ord_row['recipient_mobile'] or ""
address = ord_row['address'] or ""
# Insert into return_receivings
cur_return.execute("""
INSERT INTO return_receivings
(receive_date, tracking_no, mall, receiver_name, phone, address, remarks, receive_status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (today, tracking_no, mall, receiver_name, phone, address, remarks, "환불 대기"))
success_count += 1
conn_return.commit()
conn_return.close()
conn_order.close()
return {"success": True, "count": success_count}
except Exception as e:
print(f"Bulk insert error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/receive")
async def create_return_receive(req: ReturnReceive):
try:
conn = get_return_db_conn()
cur = conn.cursor()
if not req.id:
cur.execute("SELECT request_type, remarks FROM return_requests WHERE tracking_no = %s", (req.tracking_no,))
req_row = cur.fetchone()
if not req_row:
if req.remarks:
if "고객이 반품" not in req.remarks:
req.remarks = "고객이 반품 / " + req.remarks
else:
req.remarks = "고객이 반품"
else:
req_type = req_row['request_type'] or ""
req_remarks = req_row['remarks'] or ""
auto_remark = f"{req_type}/{req_remarks}" if req_remarks else req_type
if not req.remarks:
req.remarks = auto_remark
elif auto_remark not in req.remarks:
req.remarks = f"{auto_remark} / {req.remarks}"
if req.id:
# Update existing record
cur.execute("""
UPDATE return_receivings
SET receive_date=%s, tracking_no=%s, mall=%s, receiver_name=%s, phone=%s, address=%s, remarks=%s, receive_status=%s
WHERE id=%s
""", (req.receive_date, req.tracking_no, req.mall, req.receiver_name, req.phone, req.address, req.remarks, req.receive_status, req.id))
else:
# Insert new record
cur.execute("""
INSERT INTO return_receivings
(receive_date, tracking_no, mall, receiver_name, phone, address, remarks, receive_status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (req.receive_date, req.tracking_no, req.mall, req.receiver_name, req.phone, req.address, req.remarks, req.receive_status))
conn.commit()
conn.close()
return {"success": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/receive")
async def get_return_receives():
try:
conn = get_return_db_conn()
cur = conn.cursor()
cur.execute("SELECT * FROM return_receivings ORDER BY receive_date DESC, mall ASC, id DESC LIMIT 50")
rows = cur.fetchall()
conn.close()
return {"success": True, "data": [dict(r) for r in rows]}
except Exception as e:
return {"success": False, "message": str(e)}
@router.delete("/request/{item_id}")
async def delete_return_request(item_id: int):
try:
conn = get_return_db_conn()
cur = conn.cursor()
cur.execute("DELETE FROM return_requests WHERE id = %s", (item_id,))
conn.commit()
conn.close()
return {"success": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/receive/{item_id}")
async def delete_return_receive(item_id: int):
try:
conn = get_return_db_conn()
cur = conn.cursor()
cur.execute("DELETE FROM return_receivings WHERE id = %s", (item_id,))
conn.commit()
conn.close()
return {"success": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
+3018
View File
File diff suppressed because it is too large Load Diff
+1052
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
<section id="version-history-tab" class="tab-content"
style="padding: 20px; overflow-y: auto; height: 100%; position: relative; color: #333;">
<div class="glass-card"
style="max-width: 1200px; margin: 0 auto; padding: 30px; background: rgba(255, 255, 255, 0.95);">
<h1
style="color: #2b6cb0; border-bottom: 2px solid #3182ce; padding-bottom: 10px; margin-bottom: 30px; font-size: 2.2rem; display: flex; align-items: center; gap: 10px;">
<span>🚀</span> 업데이트 내역 (Ver History)
</h1>
{% set section_config = {
'added': {
'bg_color': 'rgba(229, 62, 62, 0.05)',
'border_color': '#e53e3e',
'title_color': '#c53030',
'icon': '✨',
'title': '신규 기능 추가 (Added)'
},
'modified': {
'bg_color': 'rgba(49, 130, 206, 0.05)',
'border_color': '#3182ce',
'title_color': '#2b6cb0',
'icon': '⚡',
'title': '기능 개선 및 버그 수정 (Modified & Fixed)'
}
} %}
<div class="timeline" style="position: relative; padding-left: 30px; border-left: 3px solid #e2e8f0;">
{% for version in version_history %}
<!-- Version {{ version.version }} -->
<div class="timeline-item" style="position: relative; margin-bottom: {% if loop.last %}20px{% else %}40px{% endif %};">
<div
style="position: absolute; left: -38px; top: 0; width: 14px; height: 14px; background: {{ version.get('dot_color', '#e53e3e') }}; border-radius: 50%; border: 3px solid #fff; box-shadow: 0 0 0 2px {{ version.get('dot_color', '#e53e3e') }};">
</div>
<h2
style="font-size: 1.7rem; color: {% if loop.last %}#4a5568{% else %}#1a202c{% endif %}; margin-bottom: 15px; display: flex; align-items: baseline; gap: 10px;">
{{ version.version }} <span style="font-size: 1.1rem; color: {% if loop.last %}#a0aec0{% else %}#718096{% endif %}; font-weight: normal;">({{ version.date | safe }})</span>
</h2>
{% for section in version.sections %}
{% set config = section_config[section['type']] %}
<div
style="background: {{ config.bg_color }}; border-radius: 8px; padding: 15px; {% if not loop.last %}margin-bottom: 10px; {% endif %}border-left: 4px solid {{ config.border_color }};">
<h4
style="font-size: 1.3rem; color: {{ config.title_color }}; margin-bottom: 10px; display: flex; align-items: center;">
<span style="font-size: 1.4rem; margin-right: 8px;">{{ config.icon }}</span> {{ config.title }}
</h4>
<ul style="padding-left: 24px; font-size: 1.15rem; color: #2d3748; line-height: 1.7;">
{% for item in section['items'] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</section>
+261
View File
@@ -0,0 +1,261 @@
[
{
"version": "v8.56",
"date": "2026.05.17",
"sections": [
{
"items": [
"송장일괄 입고시 송장앞 숫자 4자리 고정 입력 기능 추가"
],
"type": "added"
},
{
"items": [
"반품 관리에서 송장 일괄 입고 시 주소 입력이 누락되는 문제 수정",
"반품 관리에서 송장 일괄 입고 시 Ctrl+Enter키 입력으로도 입고 등록 가능하도록 추가",
"반품 관리에서 반품 신청 및 입고 등록, 목록삭제 시 양쪽 목록 리스트 동시 자동 갱신 적용",
"반품 관리에서 반품 신청/입고 폼의 일부 정보(쇼핑몰, 이름, 휴대폰번호, 주소) 읽기 전용(수정 불가)으로 변경"
],
"type": "modified"
}
]
},
{
"version": "v8.55",
"date": "2026.05.14",
"sections": [
{
"items": [
"프로그램 버전업 안내 팝업 기능 추가"
],
"type": "added"
},
{
"items": [
"반품 입고가 완료된 내역은 신청 목록에서 취소선 및 붉은색 [입고완료] 표시로 자동 구분되도록 개선",
"반품 신청 목록 우측에 '입고완료 제외' 체크박스를 신설하여 보기 쉽게 필터링 가능",
"상품의 수가 많아서 직접입력으로 입력하여 계산한 것을 다시 클릭 했을 때 큰 숫자가 안보이는 문제 해결",
"무료배송 상품이어도 추가배송비 설정 하면 추가 배송비를 적용(안내 문구도 수정)",
"문자 메시지 문구 설정 후 적용 안되는 문제 해결"
],
"type": "modified"
}
]
},
{
"version": "v8.5",
"date": "2026.05.12",
"sections": [
{
"items": [
"반품 관리(반품 신청 및 입고 처리) 메뉴 추가"
],
"type": "added"
}
]
},
{
"version": "v8.30",
"date": "2026.05.11",
"sections": [
{
"items": [
"단품 및 세트 코드표 관리 기능 추가 (PostgreSQL 서버 연동)"
],
"type": "added"
},
{
"items": [
"왼쪽 메뉴(탭) 순서 전면 개편 ('통합 작업'을 'CS 작업'으로 명칭 변경)"
],
"type": "modified"
}
]
},
{
"dot_color": "#ed8936",
"version": "v8.26",
"date": "2026.05.07",
"sections": [
{
"items": [
"\"샘플 발송\" 옵션 팝업에 추가 (선택 시 전체 상품 제외 처리 및 최종 금액 0원 반영)"
],
"type": "added"
}
]
},
{
"version": "v8.25",
"date": "2026.05.05",
"sections": [
{
"items": [
"적립금 차감 로직 신설 (입력 액수만큼 최종금액 차감 적용)"
],
"type": "added"
},
{
"items": [
"설정 탭에서 기본 배송비, 추가 배송비를 직접 입력하여 관리 가능하도록 기능 개선",
"[배송비 3,500원] 고정 체크박스 삭제",
"추가 배송비 체크박스 텍스트가 설정의 추가배송비 설정 값에 즉시 연동되는 UI 편의 개선"
],
"type": "modified"
}
]
},
{
"dot_color": "#6b46c1",
"version": "v8.21",
"date": "2026.04.29",
"sections": [
{
"items": [
"수량 선택 시 기존 드롭다운 리스트 기능과 '직접입력' 방식을 통합",
"직접 입력 칸 내용을 지울 시 기존 드롭다운 리스트로 자동 복구되는 스마트 로직 추가"
],
"type": "modified"
}
]
},
{
"dot_color": "#319795",
"version": "v8.2",
"date": "2026.04.23",
"sections": [
{
"items": [
"모바일로 접속 시 모바일 화면에 맞게 UI변경",
"6종 할인 쿠폰 혜택(2천원~2만5천원) 추가 및 확인 문자 연동",
"이름(한글 3글자) 및 연락처 입력란 클릭 시 클립보드 자동 붙여넣기 편의 기능 추가"
],
"type": "added"
},
{
"items": [
"누락, 파손, 불량, 뚜껑 없이 통만 옵션 선택 시 배송비 및 개별 상품 가격 자동 0원 처리 로직 구축",
"수동 일반 발주 팝업 창 리스트에 선택 사유(누락, 파손, 뚜껑(X) 등) 전용 뱃지 표기 기능 추가",
"쿠폰 결제 할인과 기존 5% 할인 동시 접근 시 상호 배타적 디자인(클릭 불가 처리) 적용",
"상품 리스트 세로 간격을 1px 감소 시켜 조금 더 촘촘하고 눈에 잘 보이게 UI 최적화"
],
"type": "modified"
}
]
},
{
"dot_color": "#d53f8c",
"version": "v8.13",
"date": "2026.04.22",
"sections": [
{
"items": [
"발주 팝업창에서 배송비(자동/수동 설정 포함) 및 5% 할인 내역을 명시적으로 표기하는 기능 추가",
"발주 확인 창의 최종 결제 금액 산출 방식에 택배비 및 할인 금액을 완전히 연동하여 반영토록 개선"
],
"type": "added"
}
]
},
{
"version": "v8.12",
"date": "2026.04.20",
"sections": [
{
"items": [
"발주 입력 전, 결제 계산 금액에서 제외할 항목을 고를 수 있는 금액 제외 팝업창 추가",
"제외된 상품 금액에 빨간색 취소선이 표시되어 시각적으로 확실히 구분되도록 UI 개선"
],
"type": "added"
},
{
"items": [
"수동발주 내역을 접속한 모든 컴퓨터에서 공유할 수 있도록 동기화 구축",
"최근 전송 메시지 등에서 내역 복원 시, 동일 품번(묶음 옵션)의 모든 품목들이 정상적으로 노란색 하이라이트되도록 수정"
],
"type": "modified"
}
]
},
{
"dot_color": "#3182ce",
"version": "v8.1",
"date": "2026.04.18",
"sections": [
{
"items": [
"당일 수동발주 리스트 창에 입력 건수와 금액 표기",
"당일 수동발주 리스트 구글 시트 삭제 연동 (엑셀 다운로드 비우기 기능과 무관하게 로컬 기록 독립 유지)",
"당일 누적 수동발주 내역 클립보드 원클릭 복사 버튼"
],
"type": "added"
},
{
"items": [
"연락처 입력 시 '010' 제외 하고 입력 가능",
"아이템 선택 시 선택한 아이템 노란색 하이라이트 효과 적용",
"최근 전송 메시지 및 수동발주 기록 창 세로 높이를 각 260px 맞춤 최적화",
"수동발주 구글 시트 추가 시 최종 결제금액을 마지막 행 C열 숫자로 자동 산출 입력",
"최근 전송 메시지 클릭 시 좌측 옵션 체크박스 자동 선택 및 노란색 뚜렷한 하이라이트 효과 적용"
],
"type": "modified"
}
]
},
{
"dot_color": "#805ad5",
"version": "v8.05",
"date": "2026.04.17",
"sections": [
{
"items": [
"화면 글꼴 변경 기능 추가"
],
"type": "added"
},
{
"items": [
"상품 설정 탭에서 상품을 마우스로 이동 하여 편집 가능",
"상품 설정 내용을 별도의 구글시트에 저장"
],
"type": "modified"
}
]
},
{
"dot_color": "#38a169",
"version": "v8.01",
"date": "2026.04.16",
"sections": [
{
"items": [
"상품 설정 탭에서 상품을 직접 수정",
"문자메시지 머리말, 꼬리말 사용자 직접 설정"
],
"type": "added"
},
{
"items": [
"그룹 제목에 아이콘 추가",
"연락처 입력 시 전화번호 사이제 \"-\" 자동 삽입"
],
"type": "modified"
}
]
},
{
"dot_color": "#a0aec0",
"version": "v8.0",
"date": "2026.04.14\n &nbsp;|&nbsp; 초기 버전 런칭",
"sections": [
{
"items": [
"기존 로컬에서 실행되던 프로그램을 웹버전으로 전환과 함게 완전 통합된 프로그램",
"주문 처리, 배송비 자동 계산, 조건 금액별 사은품 룰셋 통합 시스템 구성",
"빠른 복사-붙여넣기를 위한 자동 고객 안내 문자 생성, 구글 시트 자동 저장 연동"
],
"type": "added"
}
]
}
]