423baff73b
- Docker bridge 网络隔离(8000 端口封死) - Gunicorn 4 Worker 多进程 - Alembic 数据库迁移基线 - 日志轮转 20m×3 - JWT 密钥 + DB 密码 + CORS 收紧 - 3-2-1 备份链路(NAS + R740-B 冷备) - 连接池 pool_pre_ping + pool_recycle=3600
226 lines
7.4 KiB
Python
226 lines
7.4 KiB
Python
"""
|
|
批量导入/导出路由 —— 产品导入 / 客户导入 / 客户导出 / 模板下载
|
|
"""
|
|
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}"},
|
|
)
|