1060 lines
32 KiB
Python
1060 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""Local inventory file upload page.
|
|
|
|
Run:
|
|
python3 upload_inventory_page.py
|
|
|
|
Then open:
|
|
http://127.0.0.1:8080
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from email import policy
|
|
from email.parser import BytesParser
|
|
import json
|
|
import logging
|
|
from logging.handlers import RotatingFileHandler
|
|
from datetime import datetime
|
|
from html import escape
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import time
|
|
from typing import Any, Iterable
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import pandas as pd
|
|
import psycopg2
|
|
from dotenv import load_dotenv
|
|
|
|
|
|
HOST = "127.0.0.1"
|
|
PORT = 8080
|
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
|
UPLOAD_DIR = PROJECT_ROOT / "batch_inventory_data"
|
|
LOG_DIR = PROJECT_ROOT / "logs"
|
|
LOG_FILE = LOG_DIR / "upload_inventory_page.log"
|
|
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
|
|
DB_SCHEMA = "raw_data"
|
|
DB_TABLE = "wangdian_batch_inventory"
|
|
DATA_FIELD = "inventory_data"
|
|
LOGGER_NAME = "inventory_upload"
|
|
|
|
|
|
logger = logging.getLogger(LOGGER_NAME)
|
|
SENSITIVE_PATTERN = re.compile(r"(?i)(password|passwd|secret|token|authorization)\s*[:=]\s*([^\s,;]+)")
|
|
|
|
LOG_FIELD_LABELS = {
|
|
"advanced_permission_enabled": "高级权限已开启",
|
|
"app_token": "应用Token",
|
|
"bytes": "字节数",
|
|
"client": "客户端",
|
|
"columns": "列数",
|
|
"content_type": "内容类型",
|
|
"database": "数据库",
|
|
"db_schema": "数据库Schema",
|
|
"db_table": "数据库表",
|
|
"elapsed_ms": "耗时毫秒",
|
|
"encoding": "编码",
|
|
"env_path": "环境文件",
|
|
"error": "错误",
|
|
"field_name": "字段名",
|
|
"host": "主机",
|
|
"is_error": "是否错误",
|
|
"max_bytes": "最大字节数",
|
|
"max_upload_bytes": "最大上传字节数",
|
|
"message": "消息",
|
|
"log_file": "日志文件",
|
|
"original_filename": "原始文件名",
|
|
"path": "路径",
|
|
"port": "端口",
|
|
"rows": "行数",
|
|
"payload": "写入内容",
|
|
"schema": "Schema",
|
|
"sheet_name": "工作表",
|
|
"sheets": "工作表数",
|
|
"source_file": "源文件",
|
|
"stored_file": "归档文件",
|
|
"stored_filename": "归档文件名",
|
|
"suffix": "文件后缀",
|
|
"table": "表名",
|
|
"target_path": "目标路径",
|
|
"upload_dir": "上传目录",
|
|
"url": "地址",
|
|
}
|
|
|
|
|
|
def configure_logging() -> None:
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
formatter = logging.Formatter(
|
|
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
stream_handler = logging.StreamHandler(sys.stdout)
|
|
stream_handler.setFormatter(formatter)
|
|
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
|
|
file_handler.setFormatter(formatter)
|
|
root_logger = logging.getLogger()
|
|
root_logger.handlers.clear()
|
|
root_logger.setLevel(logging.INFO)
|
|
root_logger.addHandler(stream_handler)
|
|
root_logger.addHandler(file_handler)
|
|
|
|
|
|
def sanitize_value(value: Any) -> Any:
|
|
if isinstance(value, str):
|
|
return SENSITIVE_PATTERN.sub(r"\1=***", value)
|
|
return value
|
|
|
|
|
|
def log_event(level: int, event: str, **fields: Any) -> None:
|
|
safe_event = sanitize_value(event)
|
|
details = " ".join(
|
|
f"{LOG_FIELD_LABELS.get(key, key)}={sanitize_value(value)!r}" for key, value in fields.items()
|
|
)
|
|
logger.log(level, "事件=%s%s", safe_event, f" {details}" if details else "")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UploadedFile:
|
|
filename: str
|
|
content: bytes
|
|
|
|
@property
|
|
def size(self) -> int:
|
|
return len(self.content)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RawDbConfig:
|
|
host: str
|
|
port: str
|
|
user: str
|
|
password: str
|
|
name: str
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "RawDbConfig":
|
|
env_path = PROJECT_ROOT / ".env"
|
|
load_dotenv(env_path)
|
|
values = {
|
|
"host": os.getenv("RAW_DB_HOST", ""),
|
|
"port": os.getenv("RAW_DB_PORT", ""),
|
|
"user": os.getenv("RAW_DB_USER", ""),
|
|
"password": os.getenv("RAW_DB_PASSWORD", ""),
|
|
"name": os.getenv("RAW_DB_NAME", ""),
|
|
}
|
|
missing = [f"RAW_DB_{key.upper()}" for key, value in values.items() if not value]
|
|
if missing:
|
|
raise RuntimeError(f"缺少 Raw 数据库配置: {', '.join(missing)}")
|
|
log_event(
|
|
logging.INFO,
|
|
"已加载Raw数据库配置",
|
|
env_path=env_path,
|
|
database=values["name"],
|
|
)
|
|
return cls(**values)
|
|
|
|
def connect(self):
|
|
log_event(
|
|
logging.INFO,
|
|
"开始连接Raw数据库",
|
|
database=self.name,
|
|
)
|
|
return psycopg2.connect(
|
|
host=self.host,
|
|
port=self.port,
|
|
user=self.user,
|
|
password=self.password,
|
|
dbname=self.name,
|
|
)
|
|
|
|
|
|
def get_create_table_sql(schema_name: str, table_name: str) -> str:
|
|
full_table_name = f"{schema_name}.{table_name}"
|
|
return f"""
|
|
CREATE SCHEMA IF NOT EXISTS {schema_name};
|
|
|
|
CREATE TABLE IF NOT EXISTS {full_table_name} (
|
|
payload JSONB NOT NULL,
|
|
start_time TIMESTAMP WITH TIME ZONE,
|
|
end_time TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
reserved_1 TEXT,
|
|
reserved_2 TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_{table_name}_created_at ON {full_table_name} (created_at);
|
|
"""
|
|
|
|
|
|
def safe_filename(filename: str) -> str:
|
|
name = Path(filename or "inventory_file").name.strip()
|
|
name = re.sub(r"[^\w.\-\u4e00-\u9fff()() ]+", "_", name)
|
|
name = re.sub(r"\s+", "_", name)
|
|
return name or "inventory_file"
|
|
|
|
|
|
def unique_upload_path(filename: str) -> Path:
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
base_name = safe_filename(filename)
|
|
candidate = UPLOAD_DIR / f"{timestamp}_{base_name}"
|
|
counter = 1
|
|
while candidate.exists():
|
|
candidate = UPLOAD_DIR / f"{timestamp}_{counter}_{base_name}"
|
|
counter += 1
|
|
log_event(
|
|
logging.INFO,
|
|
"已分配上传归档路径",
|
|
original_filename=filename,
|
|
stored_filename=candidate.name,
|
|
target_path=candidate,
|
|
)
|
|
return candidate
|
|
|
|
|
|
def parse_inventory_upload(headers: Any, body: bytes) -> UploadedFile | None:
|
|
content_type = headers.get("Content-Type", "")
|
|
if "multipart/form-data" not in content_type:
|
|
log_event(logging.WARNING, "上传请求内容类型不支持", content_type=content_type)
|
|
return None
|
|
|
|
message = BytesParser(policy=policy.default).parsebytes(
|
|
b"Content-Type: " + content_type.encode("utf-8") + b"\r\n"
|
|
b"MIME-Version: 1.0\r\n\r\n"
|
|
+ body
|
|
)
|
|
for part in message.iter_parts():
|
|
if part.get_param("name", header="content-disposition") != "inventory_file":
|
|
continue
|
|
filename = part.get_filename()
|
|
content = part.get_payload(decode=True)
|
|
if filename and content is not None:
|
|
upload = UploadedFile(filename=filename, content=content)
|
|
log_event(
|
|
logging.INFO,
|
|
"已解析上传文件",
|
|
filename=upload.filename,
|
|
bytes=upload.size,
|
|
content_type=part.get_content_type(),
|
|
)
|
|
return upload
|
|
log_event(logging.WARNING, "上传表单缺少文件字段", field_name="inventory_file")
|
|
return None
|
|
|
|
|
|
def normalize_cell(value: Any) -> Any:
|
|
if pd.isna(value):
|
|
return None
|
|
if hasattr(value, "isoformat"):
|
|
return value.isoformat()
|
|
return value
|
|
|
|
|
|
def dataframe_to_rows(frame: pd.DataFrame, sheet_name: str | None = None) -> list[dict[str, Any]]:
|
|
frame = frame.dropna(how="all")
|
|
rows: list[dict[str, Any]] = []
|
|
for record in frame.to_dict(orient="records"):
|
|
row = {str(key): normalize_cell(value) for key, value in record.items()}
|
|
if sheet_name is not None:
|
|
row["_sheet_name"] = sheet_name
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def read_inventory_rows(file_path: Path) -> list[dict[str, Any]]:
|
|
suffix = file_path.suffix.lower()
|
|
log_event(logging.INFO, "开始解析库存文件", path=file_path, suffix=suffix)
|
|
if suffix in {".xlsx", ".xlsm", ".xls"}:
|
|
sheets = pd.read_excel(file_path, sheet_name=None, dtype=object)
|
|
include_sheet_name = len(sheets) > 1
|
|
rows: list[dict[str, Any]] = []
|
|
for sheet_name, frame in sheets.items():
|
|
sheet_rows = dataframe_to_rows(frame, sheet_name if include_sheet_name else None)
|
|
rows.extend(sheet_rows)
|
|
log_event(
|
|
logging.INFO,
|
|
"已解析Excel工作表",
|
|
path=file_path.name,
|
|
sheet_name=sheet_name,
|
|
rows=len(sheet_rows),
|
|
columns=len(frame.columns),
|
|
)
|
|
log_event(logging.INFO, "库存文件解析完成", path=file_path.name, rows=len(rows), sheets=len(sheets))
|
|
return rows
|
|
|
|
if suffix == ".csv":
|
|
last_error: Exception | None = None
|
|
for encoding in ("utf-8-sig", "gb18030"):
|
|
try:
|
|
frame = pd.read_csv(file_path, dtype=object, encoding=encoding)
|
|
rows = dataframe_to_rows(frame)
|
|
log_event(
|
|
logging.INFO,
|
|
"已解析CSV文件",
|
|
path=file_path.name,
|
|
rows=len(rows),
|
|
columns=len(frame.columns),
|
|
encoding=encoding,
|
|
)
|
|
return rows
|
|
except UnicodeDecodeError as exc:
|
|
last_error = exc
|
|
log_event(logging.WARNING, "CSV编码解析失败", path=file_path.name, encoding=encoding, error=exc)
|
|
if last_error is not None:
|
|
raise last_error
|
|
|
|
raise ValueError("仅支持 Excel 或 CSV 文件写入数据库。")
|
|
|
|
|
|
def save_inventory_file_to_raw_db(file_path: Path, original_filename: str) -> int:
|
|
started_at = time.monotonic()
|
|
rows = read_inventory_rows(file_path)
|
|
if not rows:
|
|
log_event(logging.WARNING, "文件无数据跳过Raw写入", path=file_path.name)
|
|
return 0
|
|
|
|
config = RawDbConfig.from_env()
|
|
conn = config.connect()
|
|
try:
|
|
with conn:
|
|
with conn.cursor() as cur:
|
|
log_event(logging.INFO, "开始确认Raw数据表", schema=DB_SCHEMA, table=DB_TABLE)
|
|
cur.execute(get_create_table_sql(DB_SCHEMA, DB_TABLE))
|
|
log_event(logging.INFO, "Raw数据表确认完成", schema=DB_SCHEMA, table=DB_TABLE)
|
|
upload_time = datetime.now().isoformat(timespec="seconds")
|
|
total_count = len(rows)
|
|
log_event(
|
|
logging.INFO,
|
|
"开始写入Raw数据",
|
|
schema=DB_SCHEMA,
|
|
table=DB_TABLE,
|
|
source_file=original_filename,
|
|
stored_file=file_path.name,
|
|
rows=total_count,
|
|
)
|
|
for item in rows:
|
|
payload = {
|
|
"source_file": original_filename,
|
|
"stored_file": file_path.name,
|
|
"uploaded_at": upload_time,
|
|
"total_count": total_count,
|
|
DATA_FIELD: item,
|
|
}
|
|
log_event(
|
|
logging.INFO,
|
|
"写入Raw数据库单行数据",
|
|
stored_file=file_path.name,
|
|
payload=json.dumps(payload, ensure_ascii=False),
|
|
)
|
|
cur.execute(
|
|
f"""
|
|
INSERT INTO {DB_SCHEMA}.{DB_TABLE} (
|
|
payload, start_time, end_time
|
|
) VALUES (
|
|
%s::jsonb, %s, %s
|
|
);
|
|
""",
|
|
(json.dumps(payload, ensure_ascii=False), None, None),
|
|
)
|
|
elapsed_ms = int((time.monotonic() - started_at) * 1000)
|
|
log_event(
|
|
logging.INFO,
|
|
"Raw数据写入完成",
|
|
schema=DB_SCHEMA,
|
|
table=DB_TABLE,
|
|
stored_file=file_path.name,
|
|
rows=len(rows),
|
|
elapsed_ms=elapsed_ms,
|
|
)
|
|
return len(rows)
|
|
finally:
|
|
conn.close()
|
|
log_event(logging.INFO, "Raw数据库连接已关闭", schema=DB_SCHEMA, table=DB_TABLE)
|
|
|
|
|
|
def list_uploaded_files(limit: int = 20) -> list[Path]:
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
files = [path for path in UPLOAD_DIR.iterdir() if path.is_file() and not path.name.startswith(".")]
|
|
return sorted(files, key=lambda item: item.stat().st_mtime, reverse=True)[:limit]
|
|
|
|
|
|
def human_size(size: int) -> str:
|
|
units = ["B", "KB", "MB", "GB"]
|
|
value = float(size)
|
|
for unit in units:
|
|
if value < 1024 or unit == units[-1]:
|
|
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} {unit}"
|
|
value /= 1024
|
|
return f"{size} B"
|
|
|
|
|
|
def render_recent_files(files: Iterable[Path]) -> str:
|
|
rows = []
|
|
for path in files:
|
|
stat = path.stat()
|
|
uploaded_at = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
rows.append(
|
|
"<tr>"
|
|
f"<td>{escape(path.name)}</td>"
|
|
f"<td>{escape(human_size(stat.st_size))}</td>"
|
|
f"<td>{escape(uploaded_at)}</td>"
|
|
"</tr>"
|
|
)
|
|
if not rows:
|
|
return '<p class="empty">还没有上传文件。上传成功后,最近文件会显示在这里。</p>'
|
|
return (
|
|
'<table class="file-table">'
|
|
"<thead><tr><th>文件名</th><th>大小</th><th>上传时间</th></tr></thead>"
|
|
f"<tbody>{''.join(rows)}</tbody>"
|
|
"</table>"
|
|
)
|
|
|
|
|
|
def render_page(message: str = "", is_error: bool = False) -> bytes:
|
|
message_html = ""
|
|
if message:
|
|
message_class = "message error" if is_error else "message success"
|
|
message_html = f'<div class="{message_class}">{escape(message)}</div>'
|
|
|
|
html = f"""<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>委外批次库存文件上传</title>
|
|
<style>
|
|
:root {{
|
|
--ink: #101828;
|
|
--muted: #667085;
|
|
--line: #e4e7ec;
|
|
--surface: #ffffff;
|
|
--soft: #f7f4ec;
|
|
--dark: #172033;
|
|
--accent: #087f5b;
|
|
--accent-2: #b7791f;
|
|
--accent-soft: #ecfdf3;
|
|
--danger: #b42318;
|
|
--shadow: 0 24px 70px rgba(16, 24, 40, .14);
|
|
}}
|
|
|
|
* {{
|
|
box-sizing: border-box;
|
|
}}
|
|
|
|
body {{
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
color: var(--ink);
|
|
font-family: "Avenir Next", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
background:
|
|
radial-gradient(circle at 8% 8%, rgba(183, 121, 31, .16), transparent 24rem),
|
|
radial-gradient(circle at 92% 6%, rgba(8, 127, 91, .14), transparent 26rem),
|
|
linear-gradient(180deg, #f8fafc 0%, #f3f0e8 100%);
|
|
}}
|
|
|
|
main {{
|
|
width: min(1180px, calc(100% - 36px));
|
|
margin: 0 auto;
|
|
padding: 28px 0 48px;
|
|
}}
|
|
|
|
.shell {{
|
|
overflow: hidden;
|
|
border: 1px solid rgba(228, 231, 236, .92);
|
|
border-radius: 34px;
|
|
background: rgba(255, 255, 255, .74);
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(18px);
|
|
}}
|
|
|
|
.header {{
|
|
position: relative;
|
|
padding: 20px 26px;
|
|
color: #fff;
|
|
background:
|
|
linear-gradient(135deg, rgba(23, 32, 51, .98), rgba(15, 68, 65, .95)),
|
|
radial-gradient(circle at 88% 20%, rgba(183, 121, 31, .42), transparent 18rem);
|
|
}}
|
|
|
|
.header::after {{
|
|
content: "";
|
|
position: absolute;
|
|
right: -70px;
|
|
bottom: -150px;
|
|
width: 210px;
|
|
height: 210px;
|
|
border: 1px solid rgba(255, 255, 255, .12);
|
|
border-radius: 50%;
|
|
box-shadow: -42px -26px 0 rgba(255, 255, 255, .05);
|
|
}}
|
|
|
|
.nav {{
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 16px;
|
|
margin-bottom: 14px;
|
|
}}
|
|
|
|
.brand {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
font-size: 14px;
|
|
font-weight: 900;
|
|
letter-spacing: .08em;
|
|
}}
|
|
|
|
.brand-icon {{
|
|
display: grid;
|
|
place-items: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 10px;
|
|
color: var(--dark);
|
|
background: #f2c46d;
|
|
font-weight: 900;
|
|
}}
|
|
|
|
.usage {{
|
|
padding: 7px 11px;
|
|
border: 1px solid rgba(255, 255, 255, .16);
|
|
border-radius: 999px;
|
|
color: rgba(255, 255, 255, .78);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}}
|
|
|
|
.title-row {{
|
|
position: relative;
|
|
z-index: 1;
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 320px);
|
|
gap: 18px;
|
|
align-items: center;
|
|
}}
|
|
|
|
h1 {{
|
|
max-width: 760px;
|
|
margin: 0;
|
|
font-size: clamp(28px, 4vw, 42px);
|
|
line-height: 1.04;
|
|
letter-spacing: -1.5px;
|
|
}}
|
|
|
|
.subtitle {{
|
|
max-width: 720px;
|
|
margin: 10px 0 0;
|
|
color: rgba(255, 255, 255, .74);
|
|
font-size: 15px;
|
|
line-height: 1.58;
|
|
}}
|
|
|
|
.storage-card {{
|
|
width: min(330px, 100%);
|
|
padding: 13px 14px;
|
|
border: 1px solid rgba(255, 255, 255, .14);
|
|
border-radius: 16px;
|
|
background: rgba(255, 255, 255, .09);
|
|
}}
|
|
|
|
.storage-card span {{
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
color: rgba(255, 255, 255, .58);
|
|
font-size: 12px;
|
|
font-weight: 900;
|
|
letter-spacing: .12em;
|
|
}}
|
|
|
|
.storage-card strong {{
|
|
display: block;
|
|
overflow-wrap: anywhere;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
}}
|
|
|
|
.workspace {{
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr);
|
|
gap: 24px;
|
|
padding: 26px;
|
|
background:
|
|
linear-gradient(180deg, rgba(248, 250, 252, .96), rgba(247, 244, 236, .96));
|
|
}}
|
|
|
|
.drop-panel, .side-panel, .recent {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 28px;
|
|
background: var(--surface);
|
|
box-shadow: 0 16px 44px rgba(16, 24, 40, .08);
|
|
}}
|
|
|
|
.drop-panel {{
|
|
padding: 18px;
|
|
}}
|
|
|
|
.message {{
|
|
margin-bottom: 16px;
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
font-weight: 800;
|
|
}}
|
|
|
|
.success {{
|
|
color: #05603a;
|
|
background: #ecfdf3;
|
|
border: 1px solid #abefc6;
|
|
}}
|
|
|
|
.error {{
|
|
color: var(--danger);
|
|
background: #fef3f2;
|
|
border: 1px solid #fecdca;
|
|
}}
|
|
|
|
.dropzone {{
|
|
display: grid;
|
|
place-items: center;
|
|
min-height: 470px;
|
|
padding: 44px;
|
|
border: 2px dashed #98a2b3;
|
|
border-radius: 24px;
|
|
text-align: center;
|
|
background:
|
|
linear-gradient(#fff, #fff) padding-box,
|
|
repeating-linear-gradient(45deg, rgba(8, 127, 91, .08) 0 10px, rgba(183, 121, 31, .08) 10px 20px);
|
|
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease, background .18s ease;
|
|
}}
|
|
|
|
.dropzone.dragging {{
|
|
border-color: var(--accent);
|
|
background:
|
|
linear-gradient(#f0fdf4, #ffffff) padding-box,
|
|
repeating-linear-gradient(45deg, rgba(8, 127, 91, .16) 0 10px, rgba(183, 121, 31, .10) 10px 20px);
|
|
box-shadow: inset 0 0 0 8px rgba(8, 127, 91, .08);
|
|
transform: scale(.992);
|
|
}}
|
|
|
|
.drop-icon {{
|
|
display: grid;
|
|
place-items: center;
|
|
width: 108px;
|
|
height: 108px;
|
|
margin: 0 auto 26px;
|
|
border-radius: 32px;
|
|
color: #fff;
|
|
background:
|
|
linear-gradient(135deg, var(--accent), #0f5f56 62%, var(--accent-2));
|
|
box-shadow: 0 18px 40px rgba(8, 127, 91, .28);
|
|
font-size: 48px;
|
|
font-weight: 900;
|
|
}}
|
|
|
|
.drop-kicker {{
|
|
margin-bottom: 12px;
|
|
color: var(--accent-2);
|
|
font-size: 12px;
|
|
font-weight: 900;
|
|
letter-spacing: .18em;
|
|
}}
|
|
|
|
.dropzone h2 {{
|
|
margin: 0;
|
|
font-size: clamp(30px, 4vw, 48px);
|
|
line-height: 1.05;
|
|
letter-spacing: -1.6px;
|
|
}}
|
|
|
|
.dropzone p {{
|
|
max-width: 520px;
|
|
margin: 16px auto 0;
|
|
color: var(--muted);
|
|
font-size: 16px;
|
|
line-height: 1.75;
|
|
}}
|
|
|
|
.drop-status {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 220px;
|
|
margin-top: 28px;
|
|
padding: 13px 18px;
|
|
border-radius: 999px;
|
|
color: #344054;
|
|
background: #f9fafb;
|
|
border: 1px solid var(--line);
|
|
font-weight: 900;
|
|
}}
|
|
|
|
.dropzone.dragging .drop-status {{
|
|
color: #05603a;
|
|
background: #dcfae6;
|
|
border-color: #75e0a7;
|
|
}}
|
|
|
|
.side-panel {{
|
|
padding: 24px;
|
|
}}
|
|
|
|
.side-panel h2, .recent h2 {{
|
|
margin: 0 0 16px;
|
|
font-size: 22px;
|
|
letter-spacing: -.4px;
|
|
}}
|
|
|
|
.rule-list {{
|
|
display: grid;
|
|
gap: 12px;
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}}
|
|
|
|
.rule-list li {{
|
|
display: grid;
|
|
grid-template-columns: 34px 1fr;
|
|
gap: 12px;
|
|
align-items: start;
|
|
padding: 14px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 18px;
|
|
background: #fcfcfd;
|
|
}}
|
|
|
|
.rule-index {{
|
|
display: grid;
|
|
place-items: center;
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 12px;
|
|
color: #fff;
|
|
background: var(--dark);
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
}}
|
|
|
|
.rule-list strong {{
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
font-size: 15px;
|
|
}}
|
|
|
|
.rule-list span {{
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}}
|
|
|
|
.recent {{
|
|
grid-column: 1 / -1;
|
|
padding: 24px;
|
|
}}
|
|
|
|
.file-table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
overflow: hidden;
|
|
border-radius: 18px;
|
|
background: #fff;
|
|
}}
|
|
|
|
th, td {{
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--line);
|
|
text-align: left;
|
|
font-size: 14px;
|
|
}}
|
|
|
|
th {{
|
|
color: #344054;
|
|
background: #f9fafb;
|
|
font-size: 12px;
|
|
letter-spacing: .08em;
|
|
}}
|
|
|
|
.empty {{
|
|
margin: 0;
|
|
color: var(--muted);
|
|
}}
|
|
|
|
@media (max-width: 900px) {{
|
|
main {{
|
|
width: min(100% - 24px, 760px);
|
|
padding: 22px 0 36px;
|
|
}}
|
|
|
|
.header {{
|
|
padding: 20px;
|
|
}}
|
|
|
|
.nav, .title-row {{
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
}}
|
|
|
|
.nav {{
|
|
margin-bottom: 16px;
|
|
}}
|
|
|
|
.workspace {{
|
|
grid-template-columns: 1fr;
|
|
padding: 18px;
|
|
}}
|
|
|
|
.dropzone {{
|
|
min-height: 380px;
|
|
padding: 28px;
|
|
}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="shell">
|
|
<section class="header">
|
|
<div class="nav">
|
|
<div class="brand"><span class="brand-icon">委</span><span>委外批次库存上传台</span></div>
|
|
<div class="usage">每周约 2 次使用</div>
|
|
</div>
|
|
<div class="title-row">
|
|
<div>
|
|
<h1>投递委外批次库存文件</h1>
|
|
<p class="subtitle">拖入委外仓、加工方或批次盘点文件,系统会按上传时间自动归档,便于后续同步、核对和追溯。</p>
|
|
</div>
|
|
<div class="storage-card">
|
|
<span>ARCHIVE PATH</span>
|
|
<strong>{escape(str(UPLOAD_DIR))}</strong>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="workspace">
|
|
<div class="drop-panel">
|
|
{message_html}
|
|
<div class="dropzone" id="dropzone">
|
|
<div>
|
|
<div class="drop-icon">⇣</div>
|
|
<div class="drop-kicker">DROP FILE HERE</div>
|
|
<h2>拖入文件,立即上传</h2>
|
|
<p>支持 Excel、CSV 等委外批次库存文件。松开鼠标后自动保存到 batch_inventory_data,无需点击选择文件。</p>
|
|
<div class="drop-status" id="dropStatus">等待拖入文件</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="side-panel">
|
|
<h2>上传规范</h2>
|
|
<ul class="rule-list">
|
|
<li><span class="rule-index">01</span><div><strong>文件类型</strong><span>建议上传 Excel 或 CSV 格式的委外批次库存文件。</span></div></li>
|
|
<li><span class="rule-index">02</span><div><strong>文件命名</strong><span>系统会自动追加上传时间,不需要手动改名。</span></div></li>
|
|
<li><span class="rule-index">03</span><div><strong>大小限制</strong><span>单文件最大 {escape(human_size(MAX_UPLOAD_BYTES))}。</span></div></li>
|
|
<li><span class="rule-index">04</span><div><strong>保存位置</strong><span>上传后统一进入 batch_inventory_data 目录。</span></div></li>
|
|
</ul>
|
|
</aside>
|
|
|
|
<section class="recent">
|
|
<h2>最近上传的委外批次文件</h2>
|
|
{render_recent_files(list_uploaded_files())}
|
|
</section>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
const dropzone = document.getElementById("dropzone");
|
|
const dropStatus = document.getElementById("dropStatus");
|
|
|
|
function stopDefaults(event) {{
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
|
|
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {{
|
|
document.addEventListener(eventName, stopDefaults);
|
|
}});
|
|
|
|
["dragenter", "dragover"].forEach((eventName) => {{
|
|
dropzone.addEventListener(eventName, () => {{
|
|
dropzone.classList.add("dragging");
|
|
dropStatus.textContent = "松开鼠标开始上传";
|
|
}});
|
|
}});
|
|
|
|
["dragleave", "drop"].forEach((eventName) => {{
|
|
dropzone.addEventListener(eventName, () => {{
|
|
dropzone.classList.remove("dragging");
|
|
}});
|
|
}});
|
|
|
|
dropzone.addEventListener("drop", async (event) => {{
|
|
const files = event.dataTransfer && event.dataTransfer.files;
|
|
if (!files || !files.length) {{
|
|
dropStatus.textContent = "没有检测到文件,请重新拖入。";
|
|
return;
|
|
}}
|
|
|
|
dropStatus.textContent = `上传中:${{files[0].name}}`;
|
|
const formData = new FormData();
|
|
formData.append("inventory_file", files[0], files[0].name);
|
|
|
|
try {{
|
|
const response = await fetch("/upload", {{
|
|
method: "POST",
|
|
body: formData,
|
|
}});
|
|
window.location.href = response.url || "/";
|
|
}} catch (error) {{
|
|
dropStatus.textContent = "上传失败,请重试。";
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return html.encode("utf-8")
|
|
|
|
|
|
class UploadHandler(BaseHTTPRequestHandler):
|
|
server_version = "InventoryUpload/1.0"
|
|
|
|
def do_GET(self) -> None:
|
|
parsed = urlparse(self.path)
|
|
log_event(logging.INFO, "收到GET请求", client=self.client_address[0], path=parsed.path)
|
|
if parsed.path == "/favicon.ico":
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
return
|
|
if parsed.path != "/":
|
|
log_event(logging.WARNING, "请求路径不存在", client=self.client_address[0], path=parsed.path)
|
|
self.send_error(404, "Not Found")
|
|
return
|
|
|
|
params = parse_qs(parsed.query)
|
|
message = params.get("message", [""])[0]
|
|
is_error = params.get("error", ["0"])[0] == "1"
|
|
self.respond_html(render_page(message, is_error))
|
|
|
|
def do_POST(self) -> None:
|
|
path = urlparse(self.path).path
|
|
request_started_at = time.monotonic()
|
|
log_event(logging.INFO, "开始处理POST请求", client=self.client_address[0], path=path)
|
|
if path != "/upload":
|
|
log_event(logging.WARNING, "请求路径不存在", client=self.client_address[0], path=path)
|
|
self.send_error(404, "Not Found")
|
|
return
|
|
|
|
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
|
log_event(logging.INFO, "收到上传请求", client=self.client_address[0], bytes=content_length)
|
|
if content_length <= 0:
|
|
log_event(logging.WARNING, "上传请求为空", client=self.client_address[0])
|
|
self.redirect("/", "没有收到上传文件。", is_error=True)
|
|
return
|
|
if content_length > MAX_UPLOAD_BYTES:
|
|
log_event(
|
|
logging.WARNING,
|
|
"上传文件超过大小限制",
|
|
client=self.client_address[0],
|
|
bytes=content_length,
|
|
max_bytes=MAX_UPLOAD_BYTES,
|
|
)
|
|
self.redirect("/", f"文件太大,最大支持 {human_size(MAX_UPLOAD_BYTES)}。", is_error=True)
|
|
return
|
|
|
|
upload = parse_inventory_upload(self.headers, self.rfile.read(content_length))
|
|
if upload is None:
|
|
self.redirect("/", "请选择需要上传的文件。", is_error=True)
|
|
return
|
|
|
|
target_path = unique_upload_path(upload.filename)
|
|
with target_path.open("wb") as output:
|
|
output.write(upload.content)
|
|
log_event(
|
|
logging.INFO,
|
|
"上传文件已保存",
|
|
original_filename=upload.filename,
|
|
stored_file=target_path.name,
|
|
path=target_path,
|
|
bytes=upload.size,
|
|
)
|
|
|
|
try:
|
|
saved_rows = save_inventory_file_to_raw_db(target_path, upload.filename)
|
|
except Exception as exc:
|
|
log_event(
|
|
logging.ERROR,
|
|
"上传文件写入Raw数据库失败",
|
|
stored_file=target_path.name,
|
|
error=exc,
|
|
elapsed_ms=int((time.monotonic() - request_started_at) * 1000),
|
|
)
|
|
self.redirect("/", f"文件已保存,但写入数据库失败:{exc}", is_error=True)
|
|
return
|
|
|
|
log_event(
|
|
logging.INFO,
|
|
"上传请求处理完成",
|
|
original_filename=upload.filename,
|
|
stored_file=target_path.name,
|
|
rows=saved_rows,
|
|
elapsed_ms=int((time.monotonic() - request_started_at) * 1000),
|
|
)
|
|
self.redirect("/", f"上传成功:{target_path.name},已写入 {saved_rows} 行到 {DB_SCHEMA}.{DB_TABLE}")
|
|
|
|
def respond_html(self, body: bytes) -> None:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def redirect(self, path: str, message: str, is_error: bool = False) -> None:
|
|
from urllib.parse import urlencode
|
|
|
|
log_event(logging.INFO, "页面重定向", path=path, is_error=is_error, message=message)
|
|
query = urlencode({"message": message, "error": "1" if is_error else "0"})
|
|
self.send_response(303)
|
|
self.send_header("Location", f"{path}?{query}")
|
|
self.end_headers()
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
log_event(logging.INFO, "HTTP服务日志", client=self.address_string(), message=format % args)
|
|
|
|
|
|
def main() -> int:
|
|
configure_logging()
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
log_event(
|
|
logging.INFO,
|
|
"上传服务启动中",
|
|
host=HOST,
|
|
port=PORT,
|
|
upload_dir=UPLOAD_DIR,
|
|
max_upload_bytes=MAX_UPLOAD_BYTES,
|
|
db_schema=DB_SCHEMA,
|
|
db_table=DB_TABLE,
|
|
log_file=LOG_FILE,
|
|
)
|
|
server = ThreadingHTTPServer((HOST, PORT), UploadHandler)
|
|
log_event(logging.INFO, "上传服务已启动", url=f"http://{HOST}:{PORT}", upload_dir=UPLOAD_DIR, log_file=LOG_FILE)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
log_event(logging.INFO, "收到键盘中断准备停止服务")
|
|
finally:
|
|
server.server_close()
|
|
log_event(logging.INFO, "上传服务已停止", host=HOST, port=PORT)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|