여러 엑셀 파일 업로드 시 업로드 날짜 지정 기능 및 낱개 출고 자동분석 다운로드 기능 구현
This commit is contained in:
+1
-1
@@ -25,4 +25,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|||||||
|
|
||||||
# APP_ROOT_PATH 를 uvicorn --root-path 로 넘긴다. (shell form 필요 — 환경변수 확장)
|
# APP_ROOT_PATH 를 uvicorn --root-path 로 넘긴다. (shell form 필요 — 환경변수 확장)
|
||||||
# 비어 있으면 빈 문자열이 그대로 전달되어 root_path 없이 동작한다.
|
# 비어 있으면 빈 문자열이 그대로 전달되어 root_path 없이 동작한다.
|
||||||
CMD uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --root-path "${APP_ROOT_PATH:-}"
|
CMD uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers
|
||||||
|
|||||||
+175
-33
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import String, and_, cast, func, or_, text
|
from sqlalchemy import String, and_, cast, func, or_, text
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from app.database import SessionLocal, get_db
|
from app.database import SessionLocal, get_db, get_secondary_engine
|
||||||
from app.models import Order
|
from app.models import Order
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.event_logger import log_event
|
from app.event_logger import log_event
|
||||||
@@ -562,46 +562,188 @@ def run_upload_job(files_data: list[dict], user_id: str, user_data: dict):
|
|||||||
def upload_file(
|
def upload_file(
|
||||||
files: list[UploadFile] = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
upload_token: str = Form(...),
|
upload_token: str = Form(...),
|
||||||
current_user = Depends(get_current_user)
|
custom_date: str = Form(...),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
# 1. 파일 확장자 검증
|
||||||
for file in files:
|
for file in files:
|
||||||
filename = file.filename or ""
|
filename = file.filename or ""
|
||||||
if not filename.endswith(".xls") and not filename.endswith(".xlsx"):
|
if not filename.endswith(".xls") and not filename.endswith(".xlsx"):
|
||||||
raise HTTPException(status_code=400, detail="XLS 또는 XLSX 파일만 업로드할 수 있습니다.")
|
raise HTTPException(status_code=400, detail="XLS 또는 XLSX 파일만 업로드할 수 있습니다.")
|
||||||
|
|
||||||
user_id = f"{current_user.id}:{upload_token}"
|
filenames_str = ", ".join([f.filename for f in files])
|
||||||
files_data = []
|
log_event(
|
||||||
for file in files:
|
current_user,
|
||||||
contents = file.file.read()
|
"파일 업로드 시작 (낱개 출고 자동분석)",
|
||||||
files_data.append({
|
"started",
|
||||||
"contents": contents,
|
{"filenames": filenames_str, "custom_date": custom_date}
|
||||||
"filename": file.filename or ""
|
|
||||||
})
|
|
||||||
|
|
||||||
user_data = {
|
|
||||||
"id": getattr(current_user, "id", ""),
|
|
||||||
"email": getattr(current_user, "email", None),
|
|
||||||
"name": getattr(current_user, "name", None),
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
message="업로드 작업이 등록되었습니다.",
|
|
||||||
)
|
)
|
||||||
threading.Thread(
|
|
||||||
target=run_upload_job,
|
|
||||||
args=(files_data, user_id, user_data),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
return {
|
db_records = []
|
||||||
"status": "queued",
|
try:
|
||||||
"upload_token": upload_token,
|
# 2. 파일 파싱 및 데이터베이스 레코드 임시 적재
|
||||||
"message": "업로드 작업이 시작되었습니다. 진행률 창에서 완료 상태를 확인해주세요.",
|
for file in files:
|
||||||
}
|
contents = file.file.read()
|
||||||
|
df = pd.read_excel(io.BytesIO(contents))
|
||||||
|
df = df.where(pd.notnull(df), None)
|
||||||
|
column_map = find_template_columns(list(df.columns))
|
||||||
|
|
||||||
|
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=custom_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)
|
||||||
|
|
||||||
|
# 3. 데이터베이스 벌크 저장 및 커밋
|
||||||
|
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)
|
||||||
|
db.commit()
|
||||||
|
log_event(
|
||||||
|
current_user,
|
||||||
|
"파일 업로드 DB 저장 완료",
|
||||||
|
"success",
|
||||||
|
{"filenames": filenames_str, "uploaded_count": len(db_records)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
log_event(current_user, "파일 업로드 실패", "error", {"filenames": filenames_str, "error": str(e)})
|
||||||
|
raise HTTPException(status_code=500, detail=f"데이터베이스 저장 실패: {str(e)}")
|
||||||
|
|
||||||
|
# 4. 품목 마스터(itemcode_db) 연동 및 낱개 수량 환산 합계 계산
|
||||||
|
try:
|
||||||
|
item_engine = get_secondary_engine("ITEM_DB_NAME")
|
||||||
|
|
||||||
|
single_items = {}
|
||||||
|
set_components = {}
|
||||||
|
|
||||||
|
if item_engine:
|
||||||
|
with item_engine.connect() as conn:
|
||||||
|
# 1) 단품 마스터 맵 구성
|
||||||
|
rs_single = conn.execute(text("SELECT item_code, sabangnet_code, name FROM single_items"))
|
||||||
|
for row in rs_single:
|
||||||
|
single_items[row[0]] = {
|
||||||
|
"sabangnet_code": row[1] or "",
|
||||||
|
"name": row[2] or ""
|
||||||
|
}
|
||||||
|
# 2) 세트 구성품 맵 구성
|
||||||
|
rs_comp = conn.execute(text("SELECT set_code, single_code, quantity FROM set_components"))
|
||||||
|
for row in rs_comp:
|
||||||
|
s_code = row[0]
|
||||||
|
if s_code not in set_components:
|
||||||
|
set_components[s_code] = []
|
||||||
|
set_components[s_code].append({
|
||||||
|
"single_code": row[1],
|
||||||
|
"quantity": row[2]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3) 낱개 환산 처리
|
||||||
|
single_qty_sums = {}
|
||||||
|
for order in db_records:
|
||||||
|
prod_code = (order.product_code or "").strip()
|
||||||
|
if not prod_code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
qty = 1
|
||||||
|
try:
|
||||||
|
qty = int(order.order_quantity or 1)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if prod_code in single_items:
|
||||||
|
single_qty_sums[prod_code] = single_qty_sums.get(prod_code, 0) + qty
|
||||||
|
elif prod_code in set_components:
|
||||||
|
for comp in set_components[prod_code]:
|
||||||
|
s_code = comp["single_code"]
|
||||||
|
comp_qty = comp["quantity"]
|
||||||
|
single_qty_sums[s_code] = single_qty_sums.get(s_code, 0) + (qty * comp_qty)
|
||||||
|
else:
|
||||||
|
# DB에 등록되어 있지 않은 코드는 단품으로 간주하고, 사방넷코드와 이름을 보존
|
||||||
|
single_items[prod_code] = {
|
||||||
|
"sabangnet_code": prod_code,
|
||||||
|
"name": order.product_name or ""
|
||||||
|
}
|
||||||
|
single_qty_sums[prod_code] = single_qty_sums.get(prod_code, 0) + qty
|
||||||
|
|
||||||
|
# 5. 결과 엑셀 데이터 빌드
|
||||||
|
excel_rows = []
|
||||||
|
for s_code, total_qty in single_qty_sums.items():
|
||||||
|
s_info = single_items.get(s_code, {"sabangnet_code": s_code, "name": ""})
|
||||||
|
excel_rows.append({
|
||||||
|
"상품코드[필수]": s_info["sabangnet_code"] or s_code,
|
||||||
|
"가용수량": total_qty,
|
||||||
|
"불용수량": s_info["name"] or "" # C열 헤더는 "불용수량", 실데이터는 이름!
|
||||||
|
})
|
||||||
|
|
||||||
|
df_out = pd.DataFrame(excel_rows)
|
||||||
|
if df_out.empty:
|
||||||
|
df_out = pd.DataFrame(columns=["상품코드[필수]", "가용수량", "불용수량"])
|
||||||
|
else:
|
||||||
|
df_out = df_out[["상품코드[필수]", "가용수량", "불용수량"]]
|
||||||
|
|
||||||
|
# 엑셀 파일 쓰기
|
||||||
|
excel_io = io.BytesIO()
|
||||||
|
with pd.ExcelWriter(excel_io, engine='openpyxl') as writer:
|
||||||
|
df_out.to_excel(writer, index=False)
|
||||||
|
excel_io.seek(0)
|
||||||
|
|
||||||
|
# 6. 다운로드 파일명 날짜 포맷 조합
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(custom_date, "%Y-%m-%d")
|
||||||
|
except:
|
||||||
|
dt = datetime.now()
|
||||||
|
|
||||||
|
weekdays = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"]
|
||||||
|
weekday_str = weekdays[dt.weekday()]
|
||||||
|
|
||||||
|
# 형식 예: 05월 10일 (일요일)_발주_(낱개 상품 출고).xls
|
||||||
|
filename = f"{dt.strftime('%m')}월 {dt.strftime('%d')}일 ({weekday_str})_발주_(낱개 상품 출고).xls"
|
||||||
|
encoded_filename = quote(filename)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition"
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
excel_io,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_event(current_user, "낱개 출고 분석 오류", "error", {"error": str(e)})
|
||||||
|
raise HTTPException(status_code=500, detail=f"낱개 출고 분석 및 엑셀 생성 실패: {str(e)}")
|
||||||
|
|
||||||
@router.get("/progress")
|
@router.get("/progress")
|
||||||
async def upload_progress(
|
async def upload_progress(
|
||||||
|
|||||||
+150
-1
@@ -1835,4 +1835,153 @@ input[type="checkbox"] {
|
|||||||
.nav-profile-area .btn-logout-text span { display: none; }
|
.nav-profile-area .btn-logout-text span { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Force UI Update */
|
/* ==========================================================================
|
||||||
|
Upload Date Picker Custom Calendar Styles
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
#upload-date-modal .modal-content {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-instruction-text {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-calendar-picker {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav .icon-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav .icon-btn:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-title {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekdays .weekday.holiday {
|
||||||
|
color: #dc2626; /* Sunday red */
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekdays .weekday.sat {
|
||||||
|
color: #2563eb; /* Saturday blue */
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-days {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
row-gap: 0.35rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #334155;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover:not(.other-month):not(.selected) {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.other-month {
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.sat:not(.other-month):not(.selected) {
|
||||||
|
color: #2563eb; /* Saturday blue */
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.holiday:not(.other-month):not(.selected) {
|
||||||
|
color: #dc2626; /* Sunday / Korean holiday red */
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.selected {
|
||||||
|
background-color: var(--primary-color) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-weight: 700;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 10px rgba(79, 70, 229, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-date-preview {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-date-preview span {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-date-preview strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Date Picker Modal -->
|
||||||
|
<div id="upload-date-modal" class="modal-overlay">
|
||||||
|
<div class="modal-content glass-panel" style="max-width: 400px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i class="fa-regular fa-calendar-check"></i> 업로드 날짜 지정</h2>
|
||||||
|
<button type="button" class="modal-close" id="upload-date-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="modal-instruction-text">발주 데이터의 주문 날짜로 입력될 날짜를 선택해주세요.</p>
|
||||||
|
|
||||||
|
<!-- Custom Calendar Picker Container -->
|
||||||
|
<div class="custom-calendar-picker">
|
||||||
|
<div class="calendar-nav">
|
||||||
|
<button type="button" id="cal-prev-month" class="icon-btn"><i class="fa-solid fa-chevron-left"></i></button>
|
||||||
|
<span id="cal-month-year-display" class="calendar-title">2026년 05월</span>
|
||||||
|
<button type="button" id="cal-next-month" class="icon-btn"><i class="fa-solid fa-chevron-right"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-weekdays">
|
||||||
|
<div class="weekday holiday">일</div>
|
||||||
|
<div class="weekday">월</div>
|
||||||
|
<div class="weekday">화</div>
|
||||||
|
<div class="weekday">수</div>
|
||||||
|
<div class="weekday">목</div>
|
||||||
|
<div class="weekday">금</div>
|
||||||
|
<div class="weekday sat">토</div>
|
||||||
|
</div>
|
||||||
|
<div id="calendar-days-grid" class="calendar-days">
|
||||||
|
<!-- Days will be rendered via JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selected-date-preview">
|
||||||
|
<span>선택된 날짜:</span>
|
||||||
|
<strong id="selected-date-text">선택 안 됨</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer button-row">
|
||||||
|
<button id="btn-cancel-upload-date" class="btn btn-secondary">취소</button>
|
||||||
|
<button id="btn-submit-upload-date" class="btn btn-primary" disabled>
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up"></i> 업로드 실행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
|||||||
+179
-59
@@ -270,82 +270,202 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
uploadProgressModal.style.display = 'none';
|
uploadProgressModal.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
fileUpload.addEventListener('change', async (e) => {
|
// --- Upload Date picker calendar modal elements ---
|
||||||
|
const uploadDateModal = document.getElementById('upload-date-modal');
|
||||||
|
const uploadDateClose = document.getElementById('upload-date-close');
|
||||||
|
const btnCancelUploadDate = document.getElementById('btn-cancel-upload-date');
|
||||||
|
const btnSubmitUploadDate = document.getElementById('btn-submit-upload-date');
|
||||||
|
const calPrevMonth = document.getElementById('cal-prev-month');
|
||||||
|
const calNextMonth = document.getElementById('cal-next-month');
|
||||||
|
const calMonthYearDisplay = document.getElementById('cal-month-year-display');
|
||||||
|
const calendarDaysGrid = document.getElementById('calendar-days-grid');
|
||||||
|
const selectedDateText = document.getElementById('selected-date-text');
|
||||||
|
|
||||||
|
let pendingFiles = null;
|
||||||
|
let calendarActiveDate = new Date();
|
||||||
|
let calendarSelectedDate = null;
|
||||||
|
|
||||||
|
function isKoreanHoliday(year, month, date) {
|
||||||
|
const mm = String(month + 1).padStart(2, '0');
|
||||||
|
const dd = String(date).padStart(2, '0');
|
||||||
|
const mmdd = `${mm}-${dd}`;
|
||||||
|
|
||||||
|
const solarHolidays = [
|
||||||
|
"01-01", "03-01", "05-05", "06-06", "08-15", "10-03", "10-09", "12-25"
|
||||||
|
];
|
||||||
|
if (solarHolidays.includes(mmdd)) return true;
|
||||||
|
|
||||||
|
if (year === 2026) {
|
||||||
|
const holidays2026 = [
|
||||||
|
"02-16", "02-17", "02-18", "02-19",
|
||||||
|
"05-25",
|
||||||
|
"09-24", "09-25", "09-26", "09-28"
|
||||||
|
];
|
||||||
|
return holidays2026.includes(mmdd);
|
||||||
|
}
|
||||||
|
if (year === 2025) {
|
||||||
|
const holidays2025 = [
|
||||||
|
"01-27", "01-28", "01-29", "01-30",
|
||||||
|
"05-06",
|
||||||
|
"10-05", "10-06", "10-07", "10-08"
|
||||||
|
];
|
||||||
|
return holidays2025.includes(mmdd);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
const year = calendarActiveDate.getFullYear();
|
||||||
|
const month = calendarActiveDate.getMonth();
|
||||||
|
|
||||||
|
calMonthYearDisplay.textContent = `${year}년 ${String(month + 1).padStart(2, '0')}월`;
|
||||||
|
calendarDaysGrid.innerHTML = '';
|
||||||
|
|
||||||
|
const firstDayIndex = new Date(year, month, 1).getDay();
|
||||||
|
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||||
|
const prevLastDay = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
for (let i = firstDayIndex - 1; i >= 0; i--) {
|
||||||
|
const dayDiv = document.createElement('div');
|
||||||
|
dayDiv.className = 'calendar-day other-month';
|
||||||
|
dayDiv.textContent = prevLastDay - i;
|
||||||
|
calendarDaysGrid.appendChild(dayDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdays = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"];
|
||||||
|
for (let day = 1; day <= lastDay; day++) {
|
||||||
|
const dayDiv = document.createElement('div');
|
||||||
|
dayDiv.className = 'calendar-day';
|
||||||
|
dayDiv.textContent = day;
|
||||||
|
|
||||||
|
const currentDate = new Date(year, month, day);
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
|
||||||
|
if (dayOfWeek === 0 || isKoreanHoliday(year, month, day)) {
|
||||||
|
dayDiv.classList.add('holiday');
|
||||||
|
} else if (dayOfWeek === 6) {
|
||||||
|
dayDiv.classList.add('sat');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
if (calendarSelectedDate === dateStr) {
|
||||||
|
dayDiv.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
dayDiv.addEventListener('click', () => {
|
||||||
|
calendarSelectedDate = dateStr;
|
||||||
|
calendarDaysGrid.querySelectorAll('.calendar-day.selected').forEach(el => {
|
||||||
|
el.classList.remove('selected');
|
||||||
|
});
|
||||||
|
dayDiv.classList.add('selected');
|
||||||
|
selectedDateText.textContent = `${year}년 ${month + 1}월 ${day}일 (${weekdays[dayOfWeek]})`;
|
||||||
|
btnSubmitUploadDate.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
calendarDaysGrid.appendChild(dayDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCells = firstDayIndex + lastDay;
|
||||||
|
const nextDaysToFill = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);
|
||||||
|
for (let day = 1; day <= nextDaysToFill; day++) {
|
||||||
|
const dayDiv = document.createElement('div');
|
||||||
|
dayDiv.className = 'calendar-day other-month';
|
||||||
|
dayDiv.textContent = day;
|
||||||
|
calendarDaysGrid.appendChild(dayDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calPrevMonth.addEventListener('click', () => {
|
||||||
|
calendarActiveDate.setMonth(calendarActiveDate.getMonth() - 1);
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
|
||||||
|
calNextMonth.addEventListener('click', () => {
|
||||||
|
calendarActiveDate.setMonth(calendarActiveDate.getMonth() + 1);
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeUploadDateModal = () => {
|
||||||
|
uploadDateModal.classList.remove('show');
|
||||||
|
pendingFiles = null;
|
||||||
|
fileUpload.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (uploadDateClose) uploadDateClose.addEventListener('click', closeUploadDateModal);
|
||||||
|
if (btnCancelUploadDate) btnCancelUploadDate.addEventListener('click', closeUploadDateModal);
|
||||||
|
|
||||||
|
fileUpload.addEventListener('change', (e) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
logUiEvent('파일 업로드 파일 선택', {
|
|
||||||
count: files.length,
|
pendingFiles = files;
|
||||||
filenames: Array.from(files).map(f => f.name)
|
calendarActiveDate = new Date();
|
||||||
});
|
calendarSelectedDate = null;
|
||||||
|
btnSubmitUploadDate.disabled = true;
|
||||||
|
selectedDateText.textContent = '선택 안 됨';
|
||||||
|
|
||||||
|
renderCalendar();
|
||||||
|
uploadDateModal.classList.add('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
btnSubmitUploadDate.addEventListener('click', async () => {
|
||||||
|
if (!pendingFiles || !calendarSelectedDate) return;
|
||||||
|
|
||||||
|
btnSubmitUploadDate.disabled = true;
|
||||||
|
const originalHtml = btnSubmitUploadDate.innerHTML;
|
||||||
|
btnSubmitUploadDate.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 업로드 및 분석 중...';
|
||||||
|
|
||||||
const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < pendingFiles.length; i++) {
|
||||||
formData.append('files', files[i]);
|
formData.append('files', pendingFiles[i]);
|
||||||
}
|
}
|
||||||
formData.append('upload_token', uploadToken);
|
formData.append('upload_token', uploadToken);
|
||||||
|
formData.append('custom_date', calendarSelectedDate);
|
||||||
resetUploadProgressModal();
|
|
||||||
uploadProgressModal.style.display = 'flex';
|
|
||||||
fileUpload.disabled = true;
|
|
||||||
|
|
||||||
const eventSource = new EventSource(appUrl(`/api/orders/progress?upload_token=${encodeURIComponent(uploadToken)}`));
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.status !== 'idle') {
|
|
||||||
setUploadProgress(data);
|
|
||||||
}
|
|
||||||
if (data.status === 'completed' || data.status === 'error') {
|
|
||||||
eventSource.close();
|
|
||||||
fileUpload.disabled = false;
|
|
||||||
fileUpload.value = '';
|
|
||||||
if (data.status === 'completed') {
|
|
||||||
showToast(data.message || '업로드와 검색 인덱스 반영이 완료되었습니다.', 'success');
|
|
||||||
} else {
|
|
||||||
showToast(data.message || '업로드 실패', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Upload progress parse error', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(appUrl(`/api/orders/upload`), {
|
const response = await fetch(appUrl(`/api/orders/upload`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
if (response.ok) {
|
||||||
if (res.ok) {
|
let downloadFilename = '낱개상품출고.xls';
|
||||||
if (!['completed', 'error'].includes(latestUploadProgress?.status)) {
|
const disposition = response.headers.get('Content-Disposition');
|
||||||
setUploadProgress({
|
if (disposition && disposition.indexOf('filename*=') !== -1) {
|
||||||
...(latestUploadProgress || {}),
|
const matches = disposition.split("filename*=UTF-8''");
|
||||||
status: data.status || 'queued',
|
if (matches.length > 1) {
|
||||||
message: data.message || '업로드 작업이 시작되었습니다.'
|
downloadFilename = decodeURIComponent(matches[1]);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileBlob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(fileBlob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = downloadFilename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
|
showToast('업로드 완료 및 출고 파일 다운로드 완료', 'success');
|
||||||
|
uploadDateModal.classList.remove('show');
|
||||||
} else {
|
} else {
|
||||||
showToast(data.detail || 'Upload failed', 'error');
|
let errMsg = '업로드 분석 실패';
|
||||||
setUploadProgress({
|
try {
|
||||||
status: 'error',
|
const data = await response.json();
|
||||||
message: data.detail || '업로드 실패'
|
errMsg = data.detail || errMsg;
|
||||||
});
|
} catch(_) {}
|
||||||
eventSource.close();
|
showToast(errMsg, 'error');
|
||||||
fileUpload.disabled = false;
|
|
||||||
fileUpload.value = '';
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('네트워크 오류가 발생했습니다.', 'error');
|
showToast('네트워크 오류가 발생했습니다.', 'error');
|
||||||
setUploadProgress({
|
} finally {
|
||||||
status: 'error',
|
btnSubmitUploadDate.disabled = false;
|
||||||
message: '네트워크 오류가 발생했습니다.'
|
btnSubmitUploadDate.innerHTML = originalHtml;
|
||||||
});
|
|
||||||
eventSource.close();
|
|
||||||
fileUpload.disabled = false;
|
|
||||||
fileUpload.value = '';
|
fileUpload.value = '';
|
||||||
|
pendingFiles = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user