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:
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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=*"]
|
||||||
@@ -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
@@ -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 {}
|
||||||
@@ -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 네트워크 이름으로 수정
|
||||||
+155
@@ -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 {}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
pydantic
|
||||||
|
gspread
|
||||||
|
google-auth
|
||||||
|
jinja2
|
||||||
|
python-multipart
|
||||||
|
pandas
|
||||||
|
openpyxl
|
||||||
|
xlrd
|
||||||
|
bcrypt
|
||||||
|
requests
|
||||||
|
psycopg2-binary
|
||||||
|
python-dotenv
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -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 | 초기 버전 런칭",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"기존 로컬에서 실행되던 프로그램을 웹버전으로 전환과 함게 완전 통합된 프로그램",
|
||||||
|
"주문 처리, 배송비 자동 계산, 조건 금액별 사은품 룰셋 통합 시스템 구성",
|
||||||
|
"빠른 복사-붙여넣기를 위한 자동 고객 안내 문자 생성, 구글 시트 자동 저장 연동"
|
||||||
|
],
|
||||||
|
"type": "added"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user