v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成
- Docker bridge 网络隔离(8000 端口封死) - Gunicorn 4 Worker 多进程 - Alembic 数据库迁移基线 - 日志轮转 20m×3 - JWT 密钥 + DB 密码 + CORS 收紧 - 3-2-1 备份链路(NAS + R740-B 冷备) - 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
批量导入/导出路由 —— 产品导入 / 客户导入 / 客户导出 / 模板下载
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
from app.core.exceptions import BizException, ForbiddenException
|
||||
|
||||
router = APIRouter(tags=["批量导入导出"])
|
||||
|
||||
|
||||
# ── 模板下载 ──────────────────────────────────────────
|
||||
TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "templates")
|
||||
|
||||
|
||||
@router.get("/templates/{name}", summary="下载 Excel 导入模板")
|
||||
async def download_template(
|
||||
name: str,
|
||||
):
|
||||
allowed = {
|
||||
"product_import_template.xlsx",
|
||||
"customer_import_template.xlsx",
|
||||
}
|
||||
if name not in allowed:
|
||||
raise BizException(message=f"模板 {name} 不存在")
|
||||
|
||||
file_path = os.path.join(TEMPLATES_DIR, name)
|
||||
if not os.path.exists(file_path):
|
||||
raise BizException(message=f"模板文件 {name} 未找到")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=name,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
|
||||
|
||||
# ── 产品批量导入 ──────────────────────────────────────
|
||||
@router.post("/products/import", summary="Excel 批量导入产品 SKU")
|
||||
async def import_products(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
from openpyxl import load_workbook
|
||||
from app.models.erp import ErpProductSku
|
||||
|
||||
content = await file.read()
|
||||
wb = load_workbook(io.BytesIO(content))
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
raise BizException(message="Excel 文件无可用工作表")
|
||||
|
||||
rows = list(ws.iter_rows(min_row=2, values_only=True)) # 跳过表头
|
||||
if not rows:
|
||||
raise BizException(message="Excel 中无数据行")
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(rows, start=2):
|
||||
try:
|
||||
sku_code = str(row[0] or "").strip()
|
||||
name = str(row[1] or "").strip()
|
||||
spec = str(row[2] or "").strip() or None
|
||||
standard_price = float(row[3] or 0)
|
||||
unit = str(row[4] or "桶").strip()
|
||||
warning_threshold = float(row[5] or 0)
|
||||
|
||||
if not sku_code or not name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 检查 sku_code 是否已存在
|
||||
exists = (await db.execute(
|
||||
select(func.count()).select_from(ErpProductSku).where(
|
||||
ErpProductSku.sku_code == sku_code,
|
||||
ErpProductSku.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar()
|
||||
if exists:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sku = ErpProductSku(
|
||||
sku_code=sku_code,
|
||||
name=name,
|
||||
spec=spec,
|
||||
standard_price=standard_price,
|
||||
unit=unit,
|
||||
warning_threshold=warning_threshold,
|
||||
)
|
||||
db.add(sku)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"第{i}行: {e!s}")
|
||||
|
||||
await db.commit()
|
||||
return ok(
|
||||
data={"created": created, "skipped": skipped, "errors": errors},
|
||||
message=f"导入完成:新增 {created} 条,跳过 {skipped} 条",
|
||||
)
|
||||
|
||||
|
||||
# ── 客户批量导入 ──────────────────────────────────────
|
||||
@router.post("/crm/import", summary="Excel 批量导入客户")
|
||||
async def import_customers(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
from openpyxl import load_workbook
|
||||
from app.models.crm import CrmCustomer
|
||||
|
||||
content = await file.read()
|
||||
wb = load_workbook(io.BytesIO(content))
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
raise BizException(message="Excel 文件无可用工作表")
|
||||
|
||||
rows = list(ws.iter_rows(min_row=2, values_only=True))
|
||||
if not rows:
|
||||
raise BizException(message="Excel 中无数据行")
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(rows, start=2):
|
||||
try:
|
||||
name = str(row[0] or "").strip()
|
||||
level = str(row[1] or "C").strip().upper()
|
||||
industry = str(row[2] or "").strip() or None
|
||||
contact = str(row[3] or "").strip() or None
|
||||
phone = str(row[4] or "").strip() or None
|
||||
email = str(row[5] or "").strip() or None
|
||||
address = str(row[6] or "").strip() or None
|
||||
|
||||
if not name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if level not in ("A", "B", "C"):
|
||||
level = "C"
|
||||
|
||||
customer = CrmCustomer(
|
||||
name=name,
|
||||
level=level,
|
||||
industry=industry,
|
||||
contact=contact,
|
||||
phone=phone,
|
||||
email=email,
|
||||
address=address,
|
||||
owner_id=current_user.user_id,
|
||||
)
|
||||
db.add(customer)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"第{i}行: {e!s}")
|
||||
|
||||
await db.commit()
|
||||
return ok(
|
||||
data={"created": created, "skipped": skipped, "errors": errors},
|
||||
message=f"导入完成:新增 {created} 条,跳过 {skipped} 条",
|
||||
)
|
||||
|
||||
|
||||
# ── 客户导出(仅 admin) ─────────────────────────────
|
||||
@router.get("/crm/export", summary="导出客户数据 (仅管理员)")
|
||||
async def export_customers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
# 权限校验
|
||||
if current_user.data_scope != "all" and (current_user.role_name or "").lower() != "admin":
|
||||
raise ForbiddenException("仅管理员可导出客户数据")
|
||||
|
||||
from openpyxl import Workbook
|
||||
from app.models.crm import CrmCustomer
|
||||
|
||||
stmt = select(CrmCustomer).where(CrmCustomer.is_deleted.is_(False)).order_by(CrmCustomer.created_at.desc())
|
||||
customers = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "客户列表"
|
||||
ws.append(["客户名称", "等级", "行业", "联系人", "电话", "邮箱", "地址", "AI评分", "创建时间"])
|
||||
|
||||
for c in customers:
|
||||
ws.append([
|
||||
c.name,
|
||||
c.level,
|
||||
c.industry or "",
|
||||
c.contact or "",
|
||||
c.phone or "",
|
||||
c.email or "",
|
||||
c.address or "",
|
||||
float(c.ai_score or 0),
|
||||
c.created_at.strftime("%Y-%m-%d %H:%M") if c.created_at else "",
|
||||
])
|
||||
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
buffer.seek(0)
|
||||
|
||||
filename = f"customers_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
Reference in New Issue
Block a user