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