diff --git a/app/auth.py b/app/auth.py
index 17b1c10..3b1913c 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -113,11 +113,12 @@ def get_current_user(request: Request, db: Session = Depends(get_db)):
def get_admin_user(current_user: User = Depends(get_current_user)):
- if not current_user.is_admin:
+ if not current_user.is_admin and current_user.email.lower() not in ADMIN_EMAILS:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user
+
def _safe_next(raw: Optional[str]) -> str:
"""클라이언트가 넘긴 next 를 검증. 같은 호스트의 절대경로만 허용.
diff --git a/app/routers/orders.py b/app/routers/orders.py
index 184ed37..40c66fa 100644
--- a/app/routers/orders.py
+++ b/app/routers/orders.py
@@ -420,82 +420,87 @@ def orders_to_excel_response(orders, filename: str, sheet_name: str = "Orders"):
)
-def run_upload_job(contents: bytes, filename: str, user_id: str, user_data: dict):
+def run_upload_job(files_data: list[dict], user_id: str, user_data: dict):
current_user = SimpleNamespace(**user_data)
db = SessionLocal()
+ filenames_str = ", ".join([f["filename"] for f in files_data])
try:
- set_upload_state(
- user_id,
- status="reading",
- message="엑셀 파일을 읽는 중입니다...",
- )
-
- # read_excel requires xlrd for .xls and openpyxl for .xlsx
- df = pd.read_excel(io.BytesIO(contents))
-
- # Replace NaN with None for SQLAlchemy
- df = df.where(pd.notnull(df), None)
-
- column_map = find_template_columns(list(df.columns))
-
- total_rows = len(df)
- set_upload_state(
- user_id,
- status="processing",
- message=f"총 {total_rows}건의 엑셀 데이터를 정리하는 중입니다...",
- total_rows=total_rows,
- )
-
db_records = []
- batch_size = 1000
+ total_rows = 0
- for index, row in df.iterrows():
- rlist = list(row)
- recipient_phone = row_value(rlist, column_map, "recipient_phone")
- recipient_mobile = row_value(rlist, column_map, "recipient_mobile")
- tracking_number = row_value(rlist, column_map, "tracking_number")
- address = row_value(rlist, column_map, "address")
+ # 1단계: 모든 파일 읽기 및 행 데이터 정규화
+ for f_idx, file_item in enumerate(files_data):
+ contents = file_item["contents"]
+ filename = file_item["filename"]
- order = Order(
- order_date=row_value(rlist, column_map, "order_date"),
- sequence_num=row_value(rlist, column_map, "sequence_num"),
- order_no=row_value(rlist, column_map, "order_no"),
- order_no_mall=row_value(rlist, column_map, "order_no_mall"),
- recipient_name=row_value(rlist, column_map, "recipient_name"),
- product_code=row_value(rlist, column_map, "product_code"),
- product_name=row_value(rlist, column_map, "product_name"),
- order_quantity=row_value(rlist, column_map, "order_quantity"),
- address=address,
- address_normalized=normalize_address_key(address),
- postal_code=row_value(rlist, column_map, "postal_code"),
- recipient_phone=recipient_phone,
- recipient_mobile=recipient_mobile,
- recipient_phone_normalized=normalize_phone(recipient_phone),
- recipient_mobile_normalized=normalize_phone(recipient_mobile),
- delivery_memo=row_value(rlist, column_map, "delivery_memo"),
- tracking_number=tracking_number,
- tracking_number_normalized=normalize_tracking(tracking_number),
- vendor=row_value(rlist, column_map, "vendor"),
- order_list_1=row_value(rlist, column_map, "order_list_1"),
- order_note=row_value(rlist, column_map, "order_note"),
- upload_date=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ set_upload_state(
+ user_id,
+ status="reading",
+ message=f"[{f_idx + 1}/{len(files_data)}] 엑셀 파일을 읽는 중입니다 ({filename})...",
)
- db_records.append(order)
- # Update progress periodically
- if index % 1000 == 0 and index > 0:
- calc_progress = float(index) / total_rows if total_rows else 1
+ df = pd.read_excel(io.BytesIO(contents))
+ df = df.where(pd.notnull(df), None)
+ column_map = find_template_columns(list(df.columns))
- set_upload_state(
- user_id,
- status="processing",
- message=f"{index}/{total_rows}건 엑셀 데이터 정리 중...",
- upload_progress=calc_progress * 100,
- total_rows=total_rows,
- processed_rows=index,
+ file_rows = len(df)
+ total_rows += file_rows
+
+ set_upload_state(
+ user_id,
+ status="processing",
+ message=f"[{f_idx + 1}/{len(files_data)}] {filename}의 엑셀 데이터를 정리하는 중...",
+ total_rows=total_rows,
+ )
+
+ for index, row in df.iterrows():
+ rlist = list(row)
+ recipient_phone = row_value(rlist, column_map, "recipient_phone")
+ recipient_mobile = row_value(rlist, column_map, "recipient_mobile")
+ tracking_number = row_value(rlist, column_map, "tracking_number")
+ address = row_value(rlist, column_map, "address")
+
+ order = Order(
+ order_date=row_value(rlist, column_map, "order_date"),
+ sequence_num=row_value(rlist, column_map, "sequence_num"),
+ order_no=row_value(rlist, column_map, "order_no"),
+ order_no_mall=row_value(rlist, column_map, "order_no_mall"),
+ recipient_name=row_value(rlist, column_map, "recipient_name"),
+ product_code=row_value(rlist, column_map, "product_code"),
+ product_name=row_value(rlist, column_map, "product_name"),
+ order_quantity=row_value(rlist, column_map, "order_quantity"),
+ address=address,
+ address_normalized=normalize_address_key(address),
+ postal_code=row_value(rlist, column_map, "postal_code"),
+ recipient_phone=recipient_phone,
+ recipient_mobile=recipient_mobile,
+ recipient_phone_normalized=normalize_phone(recipient_phone),
+ recipient_mobile_normalized=normalize_phone(recipient_mobile),
+ delivery_memo=row_value(rlist, column_map, "delivery_memo"),
+ tracking_number=tracking_number,
+ tracking_number_normalized=normalize_tracking(tracking_number),
+ vendor=row_value(rlist, column_map, "vendor"),
+ order_list_1=row_value(rlist, column_map, "order_list_1"),
+ order_note=row_value(rlist, column_map, "order_note"),
+ upload_date=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
+ db_records.append(order)
+ # 주기적으로 데이터 정리 진행률 갱신 (전체 누적 건수 기준)
+ current_processed = len(db_records)
+ if current_processed % 1000 == 0 and current_processed > 0:
+ calc_progress = float(current_processed) / total_rows if total_rows else 1
+ set_upload_state(
+ user_id,
+ status="processing",
+ message=f"전체 {current_processed}건 엑셀 데이터 정리 중...",
+ upload_progress=calc_progress * 100,
+ total_rows=total_rows,
+ processed_rows=current_processed,
+ )
+
+ # 2단계: FTS 인덱스 및 DB 저장 단계
set_upload_state(
user_id,
status="indexing",
@@ -506,6 +511,7 @@ def run_upload_job(contents: bytes, filename: str, user_id: str, user_data: dict
processed_rows=total_rows,
)
+ batch_size = 1000
for i in range(0, len(db_records), batch_size):
chunk = db_records[i:i + batch_size]
db.bulk_save_objects(chunk)
@@ -538,39 +544,48 @@ def run_upload_job(contents: bytes, filename: str, user_id: str, user_data: dict
current_user,
"파일 업로드 완료",
"success",
- {"filename": filename, "uploaded_count": len(db_records)},
+ {"filenames": filenames_str, "uploaded_count": len(db_records)},
)
except ValueError as e:
db.rollback()
set_upload_error(user_id, str(e))
- log_event(current_user, "파일 업로드 실패", "error", {"filename": filename, "error": str(e)})
+ log_event(current_user, "파일 업로드 실패", "error", {"filenames": filenames_str, "error": str(e)})
except Exception as e:
db.rollback()
set_upload_error(user_id, f"에러 발생: {str(e)}")
- log_event(current_user, "파일 업로드 실패", "error", {"filename": filename, "error": str(e)})
+ log_event(current_user, "파일 업로드 실패", "error", {"filenames": filenames_str, "error": str(e)})
finally:
db.close()
@router.post("/upload")
def upload_file(
- file: UploadFile = File(...),
+ files: list[UploadFile] = File(...),
upload_token: str = Form(...),
current_user = Depends(get_current_user)
):
- filename = file.filename or ""
- if not filename.endswith(".xls") and not filename.endswith(".xlsx"):
- raise HTTPException(status_code=400, detail="Only XLS or XLSX files are allowed.")
+ for file in files:
+ filename = file.filename or ""
+ if not filename.endswith(".xls") and not filename.endswith(".xlsx"):
+ raise HTTPException(status_code=400, detail="XLS 또는 XLSX 파일만 업로드할 수 있습니다.")
user_id = f"{current_user.id}:{upload_token}"
- contents = file.file.read()
+ files_data = []
+ for file in files:
+ contents = file.file.read()
+ files_data.append({
+ "contents": contents,
+ "filename": file.filename or ""
+ })
+
user_data = {
"id": getattr(current_user, "id", ""),
"email": getattr(current_user, "email", None),
"name": getattr(current_user, "name", None),
}
- log_event(current_user, "파일 업로드 시작", "started", {"filename": filename})
+ filenames_str = ", ".join([f["filename"] for f in files_data])
+ log_event(current_user, "파일 업로드 시작", "started", {"filenames": filenames_str})
set_upload_state(
user_id,
status="queued",
@@ -578,7 +593,7 @@ def upload_file(
)
threading.Thread(
target=run_upload_job,
- args=(contents, filename, user_id, user_data),
+ args=(files_data, user_id, user_data),
daemon=True,
).start()
diff --git a/static/index.html b/static/index.html
index 9c16589..7b5cd6b 100644
--- a/static/index.html
+++ b/static/index.html
@@ -90,7 +90,7 @@
(.xls, .xlsx)
-
+
diff --git a/static/js/app.js b/static/js/app.js
index 84ca5b9..2bd7705 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -261,17 +261,18 @@ document.addEventListener('DOMContentLoaded', () => {
});
fileUpload.addEventListener('change', async (e) => {
- const file = e.target.files[0];
- if (!file) return;
+ const files = e.target.files;
+ if (!files || files.length === 0) return;
logUiEvent('파일 업로드 파일 선택', {
- filename: file.name,
- size: file.size,
- type: file.type
+ count: files.length,
+ filenames: Array.from(files).map(f => f.name)
});
const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const formData = new FormData();
- formData.append('file', file);
+ for (let i = 0; i < files.length; i++) {
+ formData.append('files', files[i]);
+ }
formData.append('upload_token', uploadToken);
resetUploadProgressModal();