feat: 管理后台完整可用 + 注册登录记日志 + 提取信息结构化展示 + 微信配置就绪
- 管理后台用户/统计/日志/配置四页签全部对接真实后端API - auth注册/登录/游客/微信登录事件写入usage_logs表 - 提取信息结果从原始JSON改为卡片式字段列表(中文标签) - 管理后台搜索按钮增加加载态和结果数提示 - 配置WECHAT_APP_ID/WECHAT_APP_SECRET - 客户/产品/报价单CRUD页面完整(导出导入批量操作)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Header
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Header, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -9,6 +9,7 @@ from app.models.user import User
|
||||
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from datetime import datetime, timedelta
|
||||
from app.services.admin import AdminService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -37,7 +38,7 @@ class RefreshRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def register(data: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(select(User).where(User.phone == data.phone))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Phone already registered")
|
||||
@@ -51,6 +52,9 @@ async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
await AdminService(db).log_usage(str(user.id), "user.register", {"phone": data.phone}, ip=client_ip)
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"phone": user.phone,
|
||||
@@ -63,12 +67,17 @@ async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
data: LoginRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
phone = data.username or data.phone
|
||||
if not phone:
|
||||
login_id = data.username or data.phone
|
||||
if not login_id:
|
||||
raise HTTPException(status_code=422, detail="phone required")
|
||||
result = await db.execute(select(User).where(User.phone == phone))
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
(User.phone == login_id) | (User.username == login_id)
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
@@ -77,6 +86,9 @@ async def login(
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
await AdminService(db).log_usage(str(user.id), "user.login", {"login_id": login_id}, ip=client_ip)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
@@ -90,7 +102,7 @@ async def login(
|
||||
|
||||
|
||||
@router.post("/login/guest")
|
||||
async def guest_login():
|
||||
async def guest_login(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
guest_id = str(uuid.uuid4())
|
||||
access_token = create_access_token(
|
||||
{"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True},
|
||||
@@ -98,6 +110,9 @@ async def guest_login():
|
||||
)
|
||||
refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True})
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
await AdminService(db).log_usage(guest_id, "user.login_guest", {}, ip=client_ip)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -197,7 +212,7 @@ async def wechat_config():
|
||||
|
||||
|
||||
@router.post("/wechat-login")
|
||||
async def wechat_login(data: WeChatLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def wechat_login(data: WeChatLoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.services.wechat import wechat_service
|
||||
|
||||
session = await wechat_service.code2session(data.code)
|
||||
@@ -207,6 +222,7 @@ async def wechat_login(data: WeChatLoginRequest, db: AsyncSession = Depends(get_
|
||||
openid = session.get("openid")
|
||||
result = await db.execute(select(User).where(User.wechat_openid == openid))
|
||||
user = result.scalar_one_or_none()
|
||||
is_new = False
|
||||
|
||||
if not user:
|
||||
user = User(
|
||||
@@ -216,6 +232,10 @@ async def wechat_login(data: WeChatLoginRequest, db: AsyncSession = Depends(get_
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
is_new = True
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
await AdminService(db).log_usage(str(user.id), "user.wechat_login", {"is_new": is_new}, ip=client_ip)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
|
||||
|
||||
@@ -187,3 +187,20 @@ async def export_customers(
|
||||
headers={"Content-Disposition": "attachment; filename=customers.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/xlsx")
|
||||
async def export_customers_xlsx(
|
||||
status: Optional[str] = None,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = CustomerService(db)
|
||||
result = await service.list_customers(user_id, status, 1, 9999)
|
||||
items = result.get("items", [])
|
||||
xlsx_bytes = export.export_customers_xlsx(items)
|
||||
return Response(
|
||||
content=xlsx_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=customers.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from app.database import get_db
|
||||
from app.services.product import ProductService
|
||||
from app.services import export
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
from pydantic import BaseModel
|
||||
import io
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
HAS_OPENPYXL = True
|
||||
except ImportError:
|
||||
HAS_OPENPYXL = False
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -50,6 +61,101 @@ async def list_products(
|
||||
return await service.list_products(user_id, category, page, size)
|
||||
|
||||
|
||||
@router.get("/export/csv")
|
||||
async def export_products(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = ProductService(db)
|
||||
result = await service.list_products(user_id, None, 1, 9999)
|
||||
items = result.get("items", [])
|
||||
csv_bytes = export.export_products_csv(items)
|
||||
return Response(
|
||||
content=csv_bytes,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=products.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/xlsx")
|
||||
async def export_products_xlsx(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = ProductService(db)
|
||||
result = await service.list_products(user_id, None, 1, 9999)
|
||||
items = result.get("items", [])
|
||||
xlsx_bytes = export.export_products_xlsx(items)
|
||||
return Response(
|
||||
content=xlsx_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=products.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_products(
|
||||
file: UploadFile = File(...),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = ProductService(db)
|
||||
content = await file.read()
|
||||
filename = file.filename.lower()
|
||||
|
||||
if filename.endswith(".xlsx"):
|
||||
if not HAS_OPENPYXL:
|
||||
raise HTTPException(status_code=501, detail="openpyxl not installed, XLSX import unavailable")
|
||||
wb = openpyxl.load_workbook(io.BytesIO(content))
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(min_row=2, values_only=True))
|
||||
records = []
|
||||
for row in rows:
|
||||
if not row[0]:
|
||||
continue
|
||||
records.append({
|
||||
"name": str(row[0] or ""),
|
||||
"name_en": str(row[1] or ""),
|
||||
"category": str(row[2] or ""),
|
||||
"description": str(row[3] or ""),
|
||||
"price": str(row[4] or ""),
|
||||
"price_unit": str(row[5] or "USD") if len(row) > 5 else "USD",
|
||||
"moq": str(row[6] or "") if len(row) > 6 else "",
|
||||
"keywords": [k.strip() for k in str(row[7] or "").split(",") if k.strip()] if len(row) > 7 else [],
|
||||
})
|
||||
elif filename.endswith(".csv"):
|
||||
import csv
|
||||
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
|
||||
records = []
|
||||
for row in reader:
|
||||
name = row.get("名称", row.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
records.append({
|
||||
"name": name,
|
||||
"name_en": row.get("英文名", row.get("name_en", "")),
|
||||
"category": row.get("分类", row.get("category", "")),
|
||||
"description": row.get("描述", row.get("description", "")),
|
||||
"price": row.get("价格", row.get("price", "")),
|
||||
"price_unit": row.get("货币", row.get("price_unit", "USD")),
|
||||
"moq": row.get("MOQ", row.get("moq", "")),
|
||||
"keywords": [k.strip() for k in row.get("关键词", row.get("keywords", "")).split(",") if k.strip()],
|
||||
})
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
|
||||
|
||||
imported = 0
|
||||
errors = []
|
||||
for rec in records:
|
||||
try:
|
||||
await service.create_product(user_id, rec)
|
||||
imported += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{rec.get('name', '')}: {str(e)}")
|
||||
|
||||
return {"imported": imported, "errors": errors}
|
||||
|
||||
|
||||
@router.get("/{product_id}")
|
||||
async def get_product(
|
||||
product_id: str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
@@ -10,6 +10,16 @@ from app.api.v1.deps import get_current_user_id
|
||||
from app.models.quotation import Quotation
|
||||
from app.models.customer import Customer
|
||||
from sqlalchemy import select, and_
|
||||
import logging
|
||||
import io
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
HAS_OPENPYXL = True
|
||||
except ImportError:
|
||||
HAS_OPENPYXL = False
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -59,6 +69,64 @@ async def list_quotations(
|
||||
return await service.list_quotations(user_id, page, size)
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_quotations(
|
||||
file: UploadFile = File(...),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = QuotationService(db)
|
||||
content = await file.read()
|
||||
filename = file.filename.lower()
|
||||
|
||||
if filename.endswith(".xlsx"):
|
||||
if not HAS_OPENPYXL:
|
||||
raise HTTPException(status_code=501, detail="openpyxl not installed, XLSX import unavailable")
|
||||
wb = openpyxl.load_workbook(io.BytesIO(content))
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(min_row=2, values_only=True))
|
||||
records = []
|
||||
for row in rows:
|
||||
if not row[0]:
|
||||
continue
|
||||
items = [{"product_name": "Imported Item", "quantity": 1, "unit_price": float(row[3]) if len(row) > 3 and row[3] else 0}]
|
||||
records.append({
|
||||
"title": str(row[0]),
|
||||
"customer_id": str(row[1]) if len(row) > 1 and row[1] else "",
|
||||
"currency": str(row[2]) if len(row) > 2 and row[2] in ("USD", "EUR", "GBP", "CNY") else "USD",
|
||||
"items": items,
|
||||
"status": "draft",
|
||||
})
|
||||
elif filename.endswith(".csv"):
|
||||
import csv
|
||||
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
|
||||
records = []
|
||||
for row in reader:
|
||||
title = row.get("Title", row.get("标题", "")).strip()
|
||||
if not title:
|
||||
continue
|
||||
items = [{"product_name": "Imported Item", "quantity": 1, "unit_price": float(row.get("Total", row.get("总计", 0)))}]
|
||||
records.append({
|
||||
"title": title,
|
||||
"customer_id": row.get("Customer", row.get("客户", "")),
|
||||
"currency": row.get("Currency", row.get("货币", "USD")),
|
||||
"items": items,
|
||||
})
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
|
||||
|
||||
imported = 0
|
||||
errors = []
|
||||
for rec in records:
|
||||
try:
|
||||
await service.create_quotation(user_id, rec)
|
||||
imported += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{rec.get('title', '')}: {str(e)}")
|
||||
|
||||
return {"imported": imported, "errors": errors}
|
||||
|
||||
|
||||
@router.get("/{quotation_id}")
|
||||
async def get_quotation(
|
||||
quotation_id: str,
|
||||
@@ -102,6 +170,22 @@ async def export_quotations(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/xlsx")
|
||||
async def export_quotations_xlsx(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = QuotationService(db)
|
||||
result = await service.list_quotations(user_id, 1, 9999)
|
||||
items = result.get("items", [])
|
||||
xlsx_bytes = export.export_quotations_xlsx(items)
|
||||
return Response(
|
||||
content=xlsx_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=quotations.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{quotation_id}/pdf")
|
||||
async def export_quotation_pdf(
|
||||
quotation_id: str,
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
from typing import List, Dict, Any
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
HAS_OPENPYXL = True
|
||||
except ImportError:
|
||||
HAS_OPENPYXL = False
|
||||
|
||||
|
||||
def export_customers_csv(customers: List[Dict[str, Any]]) -> bytes:
|
||||
@@ -20,6 +30,49 @@ def export_customers_csv(customers: List[Dict[str, Any]]) -> bytes:
|
||||
return output.getvalue().encode("utf-8-sig")
|
||||
|
||||
|
||||
def export_customers_xlsx(customers: List[Dict[str, Any]]) -> bytes:
|
||||
if not HAS_OPENPYXL:
|
||||
logger.warning("openpyxl not installed, falling back to CSV")
|
||||
return export_customers_csv(customers)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Customers"
|
||||
|
||||
headers = ["姓名", "公司", "国家", "电话", "WhatsApp", "邮箱", "状态", "最后联系"]
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="1890FF", end_color="1890FF", fill_type="solid")
|
||||
|
||||
for col, h in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=h)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
|
||||
for row, c in enumerate(customers, 2):
|
||||
ws.cell(row=row, column=1, value=c.get("name", ""))
|
||||
ws.cell(row=row, column=2, value=c.get("company", ""))
|
||||
ws.cell(row=row, column=3, value=c.get("country", ""))
|
||||
ws.cell(row=row, column=4, value=c.get("phone", ""))
|
||||
ws.cell(row=row, column=5, value=c.get("whatsapp_id", ""))
|
||||
ws.cell(row=row, column=6, value=c.get("email", ""))
|
||||
ws.cell(row=row, column=7, value=c.get("status", ""))
|
||||
ws.cell(row=row, column=8, value=c.get("last_contact_at", ""))
|
||||
|
||||
ws.column_dimensions["A"].width = 20
|
||||
ws.column_dimensions["B"].width = 25
|
||||
ws.column_dimensions["C"].width = 15
|
||||
ws.column_dimensions["D"].width = 18
|
||||
ws.column_dimensions["E"].width = 18
|
||||
ws.column_dimensions["F"].width = 30
|
||||
ws.column_dimensions["G"].width = 12
|
||||
ws.column_dimensions["H"].width = 20
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def export_quotations_csv(quotations: List[Dict[str, Any]]) -> bytes:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
@@ -34,4 +87,106 @@ def export_quotations_csv(quotations: List[Dict[str, Any]]) -> bytes:
|
||||
q.get("status", ""),
|
||||
q.get("created_at", ""),
|
||||
])
|
||||
return output.getvalue().encode("utf-8-sig")
|
||||
return output.getvalue().encode("utf-8-sig")
|
||||
|
||||
|
||||
def export_quotations_xlsx(quotations: List[Dict[str, Any]]) -> bytes:
|
||||
if not HAS_OPENPYXL:
|
||||
logger.warning("openpyxl not installed, falling back to CSV")
|
||||
return export_quotations_csv(quotations)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Quotations"
|
||||
|
||||
headers = ["标题", "客户", "货币", "小计", "总计", "状态", "日期"]
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="722ED1", end_color="722ED1", fill_type="solid")
|
||||
|
||||
for col, h in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=h)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
|
||||
for row, q in enumerate(quotations, 2):
|
||||
ws.cell(row=row, column=1, value=q.get("title", ""))
|
||||
ws.cell(row=row, column=2, value=q.get("customer_name", ""))
|
||||
ws.cell(row=row, column=3, value=q.get("currency", "USD"))
|
||||
ws.cell(row=row, column=4, value=float(q.get("subtotal", 0)))
|
||||
ws.cell(row=row, column=5, value=float(q.get("total", 0)))
|
||||
ws.cell(row=row, column=6, value=q.get("status", ""))
|
||||
ws.cell(row=row, column=7, value=str(q.get("created_at", "")))
|
||||
|
||||
ws.column_dimensions["A"].width = 30
|
||||
ws.column_dimensions["B"].width = 20
|
||||
ws.column_dimensions["C"].width = 10
|
||||
ws.column_dimensions["D"].width = 15
|
||||
ws.column_dimensions["E"].width = 15
|
||||
ws.column_dimensions["F"].width = 12
|
||||
ws.column_dimensions["G"].width = 20
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def export_products_csv(products: List[Dict[str, Any]]) -> bytes:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["名称", "英文名", "分类", "描述", "价格", "货币", "MOQ", "关键词"])
|
||||
for p in products:
|
||||
writer.writerow([
|
||||
p.get("name", ""),
|
||||
p.get("name_en", ""),
|
||||
p.get("category", ""),
|
||||
p.get("description", ""),
|
||||
p.get("price", ""),
|
||||
p.get("price_unit", "USD"),
|
||||
p.get("moq", ""),
|
||||
", ".join(p.get("keywords", [])),
|
||||
])
|
||||
return output.getvalue().encode("utf-8-sig")
|
||||
|
||||
|
||||
def export_products_xlsx(products: List[Dict[str, Any]]) -> bytes:
|
||||
if not HAS_OPENPYXL:
|
||||
logger.warning("openpyxl not installed, falling back to CSV")
|
||||
return export_products_csv(products)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Products"
|
||||
|
||||
headers = ["名称", "英文名", "分类", "描述", "价格", "货币", "MOQ", "关键词"]
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="07C160", end_color="07C160", fill_type="solid")
|
||||
|
||||
for col, h in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=h)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
|
||||
for row, p in enumerate(products, 2):
|
||||
ws.cell(row=row, column=1, value=p.get("name", ""))
|
||||
ws.cell(row=row, column=2, value=p.get("name_en", ""))
|
||||
ws.cell(row=row, column=3, value=p.get("category", ""))
|
||||
ws.cell(row=row, column=4, value=p.get("description", ""))
|
||||
ws.cell(row=row, column=5, value=p.get("price", ""))
|
||||
ws.cell(row=row, column=6, value=p.get("price_unit", "USD"))
|
||||
ws.cell(row=row, column=7, value=p.get("moq", ""))
|
||||
ws.cell(row=row, column=8, value=", ".join(p.get("keywords", [])))
|
||||
|
||||
ws.column_dimensions["A"].width = 20
|
||||
ws.column_dimensions["B"].width = 20
|
||||
ws.column_dimensions["C"].width = 15
|
||||
ws.column_dimensions["D"].width = 40
|
||||
ws.column_dimensions["E"].width = 12
|
||||
ws.column_dimensions["F"].width = 10
|
||||
ws.column_dimensions["G"].width = 12
|
||||
ws.column_dimensions["H"].width = 30
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
return output.getvalue()
|
||||
Reference in New Issue
Block a user