feat(发票): 支持多附件上传和邮件发送功能
refactor(用户管理): 优化用户列表查询和备注字段处理 feat(估值): 评估报告和证书URL改为数组类型并添加下载地址 docs: 添加交易管理与用户备注功能增强实施计划 fix(邮件): 修复邮件发送接口的多附件支持问题 style: 清理注释代码和格式化文件
133
.trae/documents/交易管理与用户备注功能增强实施计划.md
Normal file
@ -0,0 +1,133 @@
|
||||
## 目标概述
|
||||
|
||||
* 加强“交易管理-发票附件与发送邮箱”能力,完善数据记录与事务保障。
|
||||
|
||||
* 改进“开票弹窗附件上传”支持多文件上传(后端存储结构)。
|
||||
|
||||
* 优化“用户管理备注与操作记录”,区分备注维度并完善日志。
|
||||
|
||||
* 覆盖单元/集成测试、数据库迁移、API文档与审计日志。
|
||||
|
||||
## 数据库与模型变更
|
||||
|
||||
* EmailSendLog
|
||||
|
||||
* 新增:`extra: JSONField(null=True)` 完整记录发送邮箱的请求数据(收件人、主题、正文、附件列表、重试信息等)。
|
||||
|
||||
* Invoice(或与开票弹窗相关的业务模型)
|
||||
|
||||
* 新增:`attachments: JSONField(null=True)` 支持多个附件URL(与弹窗上传对应)。
|
||||
|
||||
<br />
|
||||
|
||||
* <br />
|
||||
|
||||
* 迁移脚本:创建/修改上述字段;保留历史数据不丢失。
|
||||
|
||||
## 事务与原子性
|
||||
|
||||
* 发送邮箱流程(交易管理)
|
||||
|
||||
* 封装在 `tortoise.transactions.in_transaction()` 中:邮件发送、EmailSendLog写入(含attachments/extra)原子提交;失败回滚。
|
||||
|
||||
* extra写入内容:完整`SendEmailRequest`(收件人、主题、正文、附件URL/文件名、重试次数、客户端UA等)。
|
||||
|
||||
* 多文件上传至发票附件(开票弹窗)
|
||||
|
||||
* 更新发票的 `attachments` 字段在同一事务内写入;如任一URL校验失败则回滚。
|
||||
|
||||
## 后端接口改造
|
||||
|
||||
* 上传组件(后端)
|
||||
|
||||
* 新增:`POST /api/v1/upload/files` 接收 `files: List[UploadFile]`,统一返回 `urls: string[]`;保留现有单文件接口向后兼容。
|
||||
|
||||
* 交易管理(管理员)
|
||||
|
||||
* `POST /api/v1/transactions/send-email` 扩展:
|
||||
|
||||
* 入参支持 `file_urls: string[]`(与/或单文件),服务端聚合附件;
|
||||
|
||||
* 在事务中记录 EmailSendLog(含attachments与extra),返回log\_id与状态;
|
||||
|
||||
* 回显接口(详情/列表)新增 `extra` 字段完整展示发送记录。
|
||||
|
||||
* 开票弹窗附件(管理员/或对应端)
|
||||
|
||||
* 新增/改造:`PUT /api/v1/invoice/{id}/attachments` 入参 `urls: string[]`,更新发票 `attachments`。
|
||||
|
||||
* 列表/详情回显 `attachments: string[]`。
|
||||
|
||||
* 用户管理备注优化(管理员端)
|
||||
|
||||
* 新接口:`PUT /api/v1/app-user-admin/{user_id}/notes`
|
||||
|
||||
* 入参:`system_notes?: string`、`user_notes?: string`(可选择性更新)
|
||||
|
||||
* 逻辑:仅更新提供的字段;不影响其他字段。
|
||||
|
||||
* 修复:`POST /api/v1/app-user-admin/quota` 仅调整次数,不再自动写入 `user.notes`。
|
||||
|
||||
* 操作日志:在调整配额、更新备注、停用启用等操作时写入 `AppUserOperationLog`。
|
||||
|
||||
## 前端改造(要点)
|
||||
|
||||
* 多文件上传组件
|
||||
|
||||
* 改为多选/拖拽支持;对每个文件显示上传进度与失败重试;
|
||||
|
||||
* 成功后收集URL数组写入发票 `attachments` 或作为邮件附件来源;
|
||||
|
||||
* 兼容旧接口:若后端仅返回单URL,前端仍正常显示(降级为单文件模式)。
|
||||
|
||||
* 开票弹窗
|
||||
|
||||
* 支持附件列表预览与移除;提交时调用 `PUT /invoice/{id}/attachments`;
|
||||
|
||||
* 邮件发送弹窗
|
||||
|
||||
* 选择附件来源(已上传的附件/本地文件再上传);提交后在详情页面完整回显 `extra`(含附件清单与正文等)。
|
||||
|
||||
## 审计与日志
|
||||
|
||||
* 关键操作:邮件发送、发票附件更新、用户备注更新、配额调整
|
||||
|
||||
* 统一调用审计记录(路由中间件已存在,补充结构化日志:`logger.info()` + DB审计表/操作日志表写入)。
|
||||
|
||||
## 测试方案
|
||||
|
||||
* 单元测试
|
||||
|
||||
* Email发送控制器:事务成功/失败回滚(模拟抛错)
|
||||
|
||||
* 多文件上传:文件类型校验、URL数组返回、尾随空白处理
|
||||
|
||||
* 备注更新:选择性字段更新、不影响其他字段
|
||||
|
||||
* 集成测试(FastAPI + httpx.AsyncClient)
|
||||
|
||||
* 发送邮箱:请求→持久化校验(attachments/extra)→回显接口校验
|
||||
|
||||
* 附件上传:批量上传、更新发票、列表/详情回显
|
||||
|
||||
* 用户备注:接口调用→DB值校验→操作日志存在
|
||||
|
||||
## 迁移与兼容
|
||||
|
||||
* 使用现有迁移工具(如Aerich)生成并应用迁移:新增JSON/Text字段;
|
||||
|
||||
* 前端保留旧接口兼容:若上传仍走单文件,后端返回数组长度1;
|
||||
|
||||
* API文档(OpenAPI)
|
||||
|
||||
* 补充/更新以上端点的schema说明、示例请求/响应、错误码约定;
|
||||
|
||||
## 实施步骤与交付
|
||||
|
||||
1. 数据模型与迁移脚本编写与应用(EmailSendLog、Invoice、AppUser/OperationLog)。
|
||||
2. 后端接口改造与事务封装(邮件发送、多文件上传、发票附件更新、备注接口)。
|
||||
3. 前端组件与弹窗改造(多文件上传、进度与错误处理、回显extra)。
|
||||
4. 审计日志与结构化日志补充。
|
||||
5. 单元与集成测试编写,覆盖核心路径。
|
||||
6. 更新接口文档与部署回归验证。
|
||||
|
||||
@ -2,10 +2,11 @@ from fastapi import APIRouter, Query, Depends
|
||||
from typing import Optional
|
||||
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
|
||||
from app.schemas.invoice import InvoiceOut, InvoiceHeaderOut, InvoiceHeaderCreate, InvoiceHeaderUpdate
|
||||
from app.schemas.invoice import InvoiceOut, InvoiceHeaderOut, InvoiceHeaderCreate, InvoiceHeaderUpdate, PaymentReceiptCreate, AppCreateInvoiceWithReceipt, InvoiceCreate
|
||||
from app.controllers.invoice import invoice_controller
|
||||
from app.utils.app_user_jwt import get_current_app_user
|
||||
from app.models.user import AppUser
|
||||
from app.models.invoice import InvoiceHeader
|
||||
|
||||
app_invoices_router = APIRouter(tags=["app-发票管理"])
|
||||
|
||||
@ -75,3 +76,56 @@ async def delete_my_header(id: int, current_user: AppUser = Depends(get_current_
|
||||
return Success(data={"deleted": False}, msg="未找到")
|
||||
ok = await invoice_controller.delete_header(id)
|
||||
return Success(data={"deleted": ok}, msg="删除成功" if ok else "未找到")
|
||||
|
||||
|
||||
@app_invoices_router.post("/receipts/{id}", summary="上传我的付款凭证", response_model=BasicResponse[dict])
|
||||
async def upload_my_receipt(id: int, data: PaymentReceiptCreate, current_user: AppUser = Depends(get_current_app_user)):
|
||||
inv = await invoice_controller.model.filter(id=id, app_user_id=current_user.id).first()
|
||||
if not inv:
|
||||
return Success(data={}, msg="未找到")
|
||||
receipt = await invoice_controller.create_receipt(id, data)
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data=detail, msg="上传成功")
|
||||
|
||||
|
||||
@app_invoices_router.post("/create-with-receipt", summary="创建我的发票并上传付款凭证", response_model=BasicResponse[dict])
|
||||
async def create_with_receipt(payload: AppCreateInvoiceWithReceipt, current_user: AppUser = Depends(get_current_app_user)):
|
||||
header = await InvoiceHeader.filter(id=payload.header_id, app_user_id=current_user.id).first()
|
||||
if not header:
|
||||
return Success(data={}, msg="抬头未找到")
|
||||
ticket_type = payload.ticket_type or "electronic"
|
||||
invoice_type = payload.invoice_type
|
||||
if not invoice_type:
|
||||
mapping = {"0": "normal", "1": "special"}
|
||||
invoice_type = mapping.get(str(payload.invoiceTypeIndex)) if payload.invoiceTypeIndex is not None else None
|
||||
if not invoice_type:
|
||||
invoice_type = "normal"
|
||||
inv_data = InvoiceCreate(
|
||||
ticket_type=ticket_type,
|
||||
invoice_type=invoice_type,
|
||||
phone=current_user.phone,
|
||||
email=header.email,
|
||||
company_name=header.company_name,
|
||||
tax_number=header.tax_number,
|
||||
register_address=header.register_address,
|
||||
register_phone=header.register_phone,
|
||||
bank_name=header.bank_name,
|
||||
bank_account=header.bank_account,
|
||||
app_user_id=current_user.id,
|
||||
header_id=header.id,
|
||||
wechat=getattr(current_user, "alias", None),
|
||||
)
|
||||
inv = await invoice_controller.create(inv_data)
|
||||
receipt = await invoice_controller.create_receipt(inv.id, PaymentReceiptCreate(url=payload.receipt_url, note=payload.note))
|
||||
detail = await invoice_controller.get_receipt_by_id(receipt.id)
|
||||
return Success(data=detail, msg="创建并上传成功")
|
||||
@app_invoices_router.get("/headers/list", summary="我的抬头列表(分页)", response_model=PageResponse[InvoiceHeaderOut])
|
||||
async def get_my_headers_paged(page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), current_user: AppUser = Depends(get_current_app_user)):
|
||||
qs = invoice_controller.model_header.filter(app_user_id=current_user.id) if hasattr(invoice_controller, "model_header") else None
|
||||
# Fallback when controller没有暴露model_header
|
||||
from app.models.invoice import InvoiceHeader
|
||||
qs = InvoiceHeader.filter(app_user_id=current_user.id)
|
||||
total = await qs.count()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
items = [InvoiceHeaderOut.model_validate(r) for r in rows]
|
||||
return SuccessExtra(data=[i.model_dump() for i in items], total=total, page=page, page_size=page_size, msg="获取成功")
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Query, Depends, HTTPException
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.schemas.base import Success, SuccessExtra, BasicResponse, PageResponse
|
||||
from app.schemas.app_user import AppUserQuotaUpdateSchema, AppUserQuotaLogOut, AppUserUpdateSchema
|
||||
@ -15,18 +16,27 @@ admin_app_users_router = APIRouter(dependencies=[DependAuth, DependPermission],
|
||||
async def list_app_users(
|
||||
phone: Optional[str] = Query(None),
|
||||
wechat: Optional[str] = Query(None),
|
||||
id: Optional[str] = Query(None),
|
||||
created_at: Optional[List[int]] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
):
|
||||
qs = AppUser.filter()
|
||||
if id is not None and id.strip().isdigit():
|
||||
qs = qs.filter(id=int(id.strip()))
|
||||
if phone:
|
||||
qs = qs.filter(phone__icontains=phone)
|
||||
if wechat:
|
||||
qs = qs.filter(alias__icontains=wechat)
|
||||
if created_at and len(created_at) == 2:
|
||||
start_dt = datetime.fromtimestamp(created_at[0] / 1000)
|
||||
end_dt = datetime.fromtimestamp(created_at[1] / 1000)
|
||||
qs = qs.filter(created_at__gte=start_dt, created_at__lte=end_dt)
|
||||
total = await qs.count()
|
||||
rows = await qs.order_by("-created_at").offset((page - 1) * page_size).limit(page_size)
|
||||
items = []
|
||||
for u in rows:
|
||||
last_log = await AppUserQuotaLog.filter(app_user_id=u.id).order_by("-created_at").first()
|
||||
items.append({
|
||||
"id": u.id,
|
||||
"phone": u.phone,
|
||||
@ -34,7 +44,7 @@ async def list_app_users(
|
||||
"created_at": u.created_at.isoformat() if u.created_at else "",
|
||||
"notes": getattr(u, "notes", "") or "",
|
||||
"remaining_count": int(getattr(u, "remaining_quota", 0) or 0),
|
||||
"user_type": None,
|
||||
"user_type": getattr(last_log, "op_type", None),
|
||||
})
|
||||
return SuccessExtra(data=items, total=total, page=page, page_size=page_size, msg="获取成功")
|
||||
|
||||
@ -94,6 +104,7 @@ async def update_app_user(user_id: int, data: AppUserUpdateSchema):
|
||||
"company_contact": getattr(user, "company_contact", None),
|
||||
"company_phone": getattr(user, "company_phone", None),
|
||||
"company_email": getattr(user, "company_email", None),
|
||||
"notes": getattr(user, "notes", None),
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else "",
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else "",
|
||||
|
||||
@ -10,6 +10,7 @@ from app.schemas.invoice import (
|
||||
InvoiceHeaderCreate,
|
||||
InvoiceHeaderUpdate,
|
||||
PaymentReceiptCreate,
|
||||
AppCreateInvoiceWithReceipt,
|
||||
InvoiceOut,
|
||||
InvoiceList,
|
||||
InvoiceHeaderOut,
|
||||
@ -19,6 +20,7 @@ from app.controllers.invoice import invoice_controller
|
||||
from app.utils.app_user_jwt import get_current_app_user
|
||||
from app.core.dependency import DependAuth, DependPermission
|
||||
from app.models.user import AppUser
|
||||
from app.models.invoice import InvoiceHeader
|
||||
|
||||
|
||||
invoice_router = APIRouter(tags=["发票管理"])
|
||||
@ -168,36 +170,3 @@ async def delete_invoice_header(id: int):
|
||||
async def update_invoice_header(id: int, data: InvoiceHeaderUpdate):
|
||||
header = await invoice_controller.update_header(id, data)
|
||||
return Success(data=header or {}, msg="更新成功" if header else "未找到")
|
||||
|
||||
|
||||
# 用户端:我的发票列表(使用App用户token)
|
||||
@invoice_router.get("/app-list", summary="我的发票列表", response_model=PageResponse[InvoiceOut])
|
||||
async def list_my_invoices(
|
||||
status: Optional[str] = Query(None),
|
||||
ticket_type: Optional[str] = Query(None),
|
||||
invoice_type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
current_user: AppUser = Depends(get_current_app_user),
|
||||
):
|
||||
result = await invoice_controller.list(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status,
|
||||
ticket_type=ticket_type,
|
||||
invoice_type=invoice_type,
|
||||
app_user_id=current_user.id,
|
||||
)
|
||||
return SuccessExtra(
|
||||
data=result.items,
|
||||
total=result.total,
|
||||
page=result.page,
|
||||
page_size=result.page_size,
|
||||
msg="获取成功",
|
||||
)
|
||||
|
||||
# 用户端:我的发票抬头(使用App用户token)
|
||||
@invoice_router.get("/app-headers", summary="我的发票抬头", response_model=BasicResponse[list[InvoiceHeaderOut]])
|
||||
async def get_my_invoice_headers(current_user: AppUser = Depends(get_current_app_user)):
|
||||
headers = await invoice_controller.get_headers(user_id=current_user.id)
|
||||
return Success(data=headers, msg="获取成功")
|
||||
|
||||
@ -4,6 +4,8 @@ from typing import Optional
|
||||
from app.schemas.base import Success, SuccessExtra, PageResponse, BasicResponse
|
||||
from app.schemas.invoice import PaymentReceiptOut
|
||||
from app.controllers.invoice import invoice_controller
|
||||
from app.models.invoice import PaymentReceipt
|
||||
from fastapi import Body
|
||||
from app.schemas.transactions import SendEmailRequest, SendEmailResponse
|
||||
from app.services.email_client import email_client
|
||||
from app.models.invoice import EmailSendLog
|
||||
@ -65,47 +67,60 @@ async def get_receipt_detail(id: int):
|
||||
return Success(data=data or {}, msg="获取成功" if data else "未找到")
|
||||
|
||||
|
||||
@transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
|
||||
async def send_email(data: SendEmailRequest, file: Optional[UploadFile] = File(None)):
|
||||
if not data.email or "@" not in data.email:
|
||||
raise HTTPException(status_code=422, detail="邮箱格式不正确")
|
||||
if not data.body:
|
||||
raise HTTPException(status_code=422, detail="文案内容不能为空")
|
||||
|
||||
file_bytes = None
|
||||
file_name = None
|
||||
if file is not None:
|
||||
file_bytes = await file.read()
|
||||
file_name = file.filename
|
||||
elif data.file_url:
|
||||
|
||||
@transactions_router.post("/send-email", summary="发送邮件", response_model=BasicResponse[SendEmailResponse])
|
||||
async def send_email(payload: SendEmailRequest = Body(...)):
|
||||
|
||||
attachments = []
|
||||
urls = []
|
||||
if payload.file_urls:
|
||||
urls.extend([u.strip().strip('`') for u in payload.file_urls if isinstance(u, str)])
|
||||
if urls:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(data.file_url)
|
||||
for u in urls:
|
||||
r = await client.get(u)
|
||||
r.raise_for_status()
|
||||
file_bytes = r.content
|
||||
file_name = data.file_url.split("/")[-1]
|
||||
attachments.append((r.content, u.split("/")[-1]))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"附件下载失败: {e}")
|
||||
|
||||
logger.info("transactions.email_send_start email={} subject={}", data.email, data.subject or "")
|
||||
result = email_client.send(data.email, data.subject, data.body, file_bytes, file_name, getattr(file, "content_type", None))
|
||||
logger.info("transactions.email_send_start email={} subject={}", payload.email, payload.subject or "")
|
||||
try:
|
||||
result = email_client.send_many(payload.email, payload.subject, payload.body, attachments)
|
||||
except RuntimeError as e:
|
||||
result = {"status": "FAIL", "error": str(e)}
|
||||
except Exception as e:
|
||||
result = {"status": "FAIL", "error": str(e)}
|
||||
|
||||
body_summary = data.body[:500]
|
||||
body_summary = payload.body[:500]
|
||||
status = result.get("status")
|
||||
error = result.get("error")
|
||||
first_name = attachments[0][1] if attachments else None
|
||||
first_url = urls[0] if urls else None
|
||||
log = await EmailSendLog.create(
|
||||
email=data.email,
|
||||
subject=data.subject,
|
||||
email=payload.email,
|
||||
subject=payload.subject,
|
||||
body_summary=body_summary,
|
||||
file_name=file_name,
|
||||
file_url=data.file_url,
|
||||
file_name=first_name,
|
||||
file_url=first_url,
|
||||
status=status,
|
||||
error=error,
|
||||
)
|
||||
if status == "OK":
|
||||
logger.info("transactions.email_send_ok email={}", data.email)
|
||||
logger.info("transactions.email_send_ok email={}", payload.email)
|
||||
else:
|
||||
logger.error("transactions.email_send_fail email={} err={}", data.email, error)
|
||||
logger.error("transactions.email_send_fail email={} err={}", payload.email, error)
|
||||
|
||||
if status == "OK" and data.receipt_id:
|
||||
try:
|
||||
r = await PaymentReceipt.filter(id=data.receipt_id).first()
|
||||
if r:
|
||||
r.extra = (r.extra or {}) | data.model_dump()
|
||||
await r.save()
|
||||
except Exception as e:
|
||||
logger.error("transactions.email_extra_save_fail id={} err={}", data.receipt_id, str(e))
|
||||
|
||||
return Success(data={"status": status, "log_id": log.id, "error": error}, msg="发送成功" if status == "OK" else "发送失败")
|
||||
|
||||
|
||||
@ -116,6 +116,9 @@ class AppUserController(CRUDBase[AppUser, AppUserRegisterSchema, AppUserUpdateSc
|
||||
op_type=op_type,
|
||||
remark=remark,
|
||||
)
|
||||
# if remark is not None:
|
||||
# user.notes = remark
|
||||
# await user.save()
|
||||
return user
|
||||
|
||||
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
|
||||
@ -33,7 +33,14 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
返回:
|
||||
InvoiceHeaderOut: 抬头输出对象
|
||||
"""
|
||||
header = await InvoiceHeader.create(app_user_id=user_id, **data.model_dump())
|
||||
payload = data.model_dump()
|
||||
for k in ["register_address", "register_phone", "bank_name", "bank_account"]:
|
||||
if payload.get(k) is None:
|
||||
payload[k] = ""
|
||||
if payload.get("is_default"):
|
||||
if user_id is not None:
|
||||
await InvoiceHeader.filter(app_user_id=user_id).update(is_default=False)
|
||||
header = await InvoiceHeader.create(app_user_id=user_id, **payload)
|
||||
return InvoiceHeaderOut.model_validate(header)
|
||||
|
||||
async def get_headers(self, user_id: Optional[int] = None) -> List[InvoiceHeaderOut]:
|
||||
@ -74,6 +81,9 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
return None
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if update_data:
|
||||
if update_data.get("is_default"):
|
||||
if header.app_user_id is not None:
|
||||
await InvoiceHeader.filter(app_user_id=header.app_user_id).exclude(id=header.id).update(is_default=False)
|
||||
await header.update_from_dict(update_data).save()
|
||||
return InvoiceHeaderOut.model_validate(header)
|
||||
|
||||
@ -178,6 +188,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
note=receipt.note,
|
||||
verified=receipt.verified,
|
||||
created_at=receipt.created_at.isoformat() if receipt.created_at else "",
|
||||
extra=receipt.extra,
|
||||
)
|
||||
|
||||
async def list_receipts(self, page: int = 1, page_size: int = 10, **filters) -> dict:
|
||||
@ -254,6 +265,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
"invoice_id": getattr(inv, "id", None),
|
||||
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
||||
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
|
||||
"extra": r.extra,
|
||||
"receipts": [
|
||||
{
|
||||
"id": r.id,
|
||||
@ -295,6 +307,7 @@ class InvoiceController(CRUDBase[Invoice, InvoiceCreate, InvoiceUpdate]):
|
||||
"invoice_id": getattr(inv, "id", None),
|
||||
"submitted_at": r.created_at.isoformat() if r.created_at else "",
|
||||
"receipt_uploaded_at": r.updated_at.isoformat() if getattr(r, "updated_at", None) else "",
|
||||
"extra": r.extra,
|
||||
"receipts": [
|
||||
{
|
||||
"id": r.id,
|
||||
|
||||
@ -190,9 +190,9 @@ class ValuationController:
|
||||
a_s_dt = _parse_time(getattr(query, 'audited_start', None))
|
||||
a_e_dt = _parse_time(getattr(query, 'audited_end', None))
|
||||
if a_s_dt:
|
||||
queryset = queryset.filter(audited_at__isnull=False, audited_at__gte=a_s_dt)
|
||||
queryset = queryset.filter(updated_at__gte=a_s_dt)
|
||||
if a_e_dt:
|
||||
queryset = queryset.filter(audited_at__isnull=False, audited_at__lte=a_e_dt)
|
||||
queryset = queryset.filter(updated_at__lte=a_e_dt)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ class InvoiceHeader(BaseModel, TimestampMixin):
|
||||
bank_name = fields.CharField(max_length=128, description="开户银行")
|
||||
bank_account = fields.CharField(max_length=64, description="银行账号")
|
||||
email = fields.CharField(max_length=128, description="接收邮箱")
|
||||
is_default = fields.BooleanField(default=False, description="是否默认抬头", index=True)
|
||||
|
||||
class Meta:
|
||||
table = "invoice_header"
|
||||
@ -29,7 +30,7 @@ class Invoice(BaseModel, TimestampMixin):
|
||||
register_phone = fields.CharField(max_length=32, description="注册电话")
|
||||
bank_name = fields.CharField(max_length=128, description="开户银行")
|
||||
bank_account = fields.CharField(max_length=64, description="银行账号")
|
||||
status = fields.CharField(max_length=16, description="状态: pending|invoiced|rejected|refunded", index=True)
|
||||
status = fields.CharField(max_length=16, description="状态: pending|invoiced|rejected|refunded", index=True, default="pending")
|
||||
app_user_id = fields.IntField(null=True, description="App用户ID", index=True)
|
||||
header = fields.ForeignKeyField("models.InvoiceHeader", related_name="invoices", null=True, description="抬头关联")
|
||||
wechat = fields.CharField(max_length=64, null=True, description="微信号", index=True)
|
||||
@ -44,6 +45,7 @@ class PaymentReceipt(BaseModel, TimestampMixin):
|
||||
url = fields.CharField(max_length=512, description="付款凭证图片地址")
|
||||
note = fields.CharField(max_length=256, null=True, description="备注")
|
||||
verified = fields.BooleanField(default=False, description="是否已核验")
|
||||
extra = fields.JSONField(null=True, description="额外信息:邮件发送相关")
|
||||
|
||||
class Meta:
|
||||
table = "payment_receipt"
|
||||
|
||||
@ -62,6 +62,7 @@ class AppUserUpdateSchema(BaseModel):
|
||||
company_contact: Optional[str] = Field(None, description="公司联系人")
|
||||
company_phone: Optional[str] = Field(None, description="公司电话")
|
||||
company_email: Optional[str] = Field(None, description="公司邮箱")
|
||||
notes: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class AppUserChangePasswordSchema(BaseModel):
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator
|
||||
|
||||
|
||||
class InvoiceHeaderCreate(BaseModel):
|
||||
company_name: str = Field(..., min_length=1, max_length=128)
|
||||
tax_number: str = Field(..., min_length=1, max_length=32)
|
||||
register_address: str = Field(..., min_length=1, max_length=256)
|
||||
register_phone: str = Field(..., min_length=1, max_length=32)
|
||||
bank_name: str = Field(..., min_length=1, max_length=128)
|
||||
bank_account: str = Field(..., min_length=1, max_length=64)
|
||||
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
email: EmailStr
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
|
||||
class InvoiceHeaderOut(BaseModel):
|
||||
@ -24,16 +25,18 @@ class InvoiceHeaderOut(BaseModel):
|
||||
email: EmailStr
|
||||
class Config:
|
||||
from_attributes = True
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
|
||||
class InvoiceHeaderUpdate(BaseModel):
|
||||
company_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
tax_number: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
register_address: Optional[str] = Field(None, min_length=1, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, min_length=1, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, min_length=1, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
register_address: Optional[str] = Field(None, max_length=256)
|
||||
register_phone: Optional[str] = Field(None, max_length=32)
|
||||
bank_name: Optional[str] = Field(None, max_length=128)
|
||||
bank_account: Optional[str] = Field(None, max_length=64)
|
||||
email: Optional[EmailStr] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
@ -105,6 +108,7 @@ class UpdateType(BaseModel):
|
||||
class PaymentReceiptCreate(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=512)
|
||||
note: Optional[str] = Field(None, max_length=256)
|
||||
extra: Optional[dict] = None
|
||||
|
||||
|
||||
class PaymentReceiptOut(BaseModel):
|
||||
@ -113,3 +117,41 @@ class PaymentReceiptOut(BaseModel):
|
||||
note: Optional[str]
|
||||
verified: bool
|
||||
created_at: str
|
||||
extra: Optional[dict] = None
|
||||
|
||||
|
||||
class AppCreateInvoiceWithReceipt(BaseModel):
|
||||
header_id: int
|
||||
ticket_type: Optional[str] = Field(None, pattern=r"^(electronic|paper)$")
|
||||
invoice_type: Optional[str] = Field(None, pattern=r"^(special|normal)$")
|
||||
# 兼容前端索引字段:"0"→normal,"1"→special
|
||||
invoiceTypeIndex: Optional[str] = None
|
||||
receipt_url: str = Field(..., min_length=1, max_length=512)
|
||||
note: Optional[str] = Field(None, max_length=256)
|
||||
|
||||
@field_validator('ticket_type', mode='before')
|
||||
@classmethod
|
||||
def _default_ticket_type(cls, v):
|
||||
return v or 'electronic'
|
||||
|
||||
@field_validator('receipt_url', mode='before')
|
||||
@classmethod
|
||||
def _clean_receipt_url(cls, v):
|
||||
if isinstance(v, list) and v:
|
||||
v = v[0]
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _coerce_invoice_type(self):
|
||||
if not self.invoice_type and self.invoiceTypeIndex is not None:
|
||||
mapping = {'0': 'normal', '1': 'special'}
|
||||
self.invoice_type = mapping.get(str(self.invoiceTypeIndex))
|
||||
# 若仍为空,默认 normal
|
||||
if not self.invoice_type:
|
||||
self.invoice_type = 'normal'
|
||||
return self
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Union
|
||||
|
||||
|
||||
class SendEmailRequest(BaseModel):
|
||||
receipt_id: Optional[int] = Field(None, description="付款凭证ID")
|
||||
email: str = Field(..., description="邮箱地址")
|
||||
subject: Optional[str] = Field(None, description="邮件主题")
|
||||
body: str = Field(..., description="文案内容")
|
||||
file_url: Optional[str] = Field(None, description="附件URL")
|
||||
file_urls: Optional[List[str]] = Field(None, description="附件URL列表")
|
||||
|
||||
|
||||
class SendEmailBody(BaseModel):
|
||||
data: SendEmailRequest
|
||||
|
||||
|
||||
class SendEmailResponse(BaseModel):
|
||||
@ -23,3 +28,7 @@ class EmailSendLogOut(BaseModel):
|
||||
file_name: Optional[str]
|
||||
file_url: Optional[str]
|
||||
status: str
|
||||
|
||||
|
||||
class SendEmailBody(BaseModel):
|
||||
data: SendEmailRequest
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Any, Dict, Union
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@ -149,12 +149,15 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
|
||||
id: int = Field(..., description="主键ID")
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
user_phone: Optional[str] = Field(None, description="用户手机号")
|
||||
report_url: Optional[str] = Field(None, description="评估报告URL")
|
||||
certificate_url: Optional[str] = Field(None, description="证书URL")
|
||||
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
|
||||
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
|
||||
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
|
||||
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
|
||||
status: str = Field(..., description="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="更新时间")
|
||||
audited_at: Optional[datetime] = Field(None, description="审核时间")
|
||||
is_active: bool = Field(..., description="是否激活")
|
||||
|
||||
class Config:
|
||||
@ -165,6 +168,29 @@ class ValuationAssessmentOut(ValuationAssessmentBase):
|
||||
# 确保所有字段都被序列化,包括None值
|
||||
exclude_none = False
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
# 用户端专用模式
|
||||
class UserValuationCreate(ValuationAssessmentBase):
|
||||
@ -176,8 +202,10 @@ class UserValuationOut(ValuationAssessmentBase):
|
||||
"""用户端估值评估输出模型"""
|
||||
id: int = Field(..., description="主键ID")
|
||||
user_id: Optional[int] = Field(None, description="用户ID")
|
||||
report_url: Optional[str] = Field(None, description="评估报告URL")
|
||||
certificate_url: Optional[str] = Field(None, description="证书URL")
|
||||
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
|
||||
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
|
||||
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
|
||||
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
|
||||
status: str = Field(..., description="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
@ -191,12 +219,37 @@ class UserValuationOut(ValuationAssessmentBase):
|
||||
}
|
||||
exclude_none = False
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
class UserValuationDetail(ValuationAssessmentBase):
|
||||
"""用户端详细估值评估模型"""
|
||||
id: int = Field(..., description="主键ID")
|
||||
report_url: Optional[str] = Field(None, description="评估报告URL")
|
||||
certificate_url: Optional[str] = Field(None, description="证书URL")
|
||||
report_url: List[str] = Field(default_factory=list, description="评估报告URL列表")
|
||||
certificate_url: List[str] = Field(default_factory=list, description="证书URL列表")
|
||||
report_download_urls: List[str] = Field(default_factory=list, description="评估报告下载地址列表")
|
||||
certificate_download_urls: List[str] = Field(default_factory=list, description="证书下载地址列表")
|
||||
status: str = Field(..., description="评估状态")
|
||||
admin_notes: Optional[str] = Field(None, description="管理员备注")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
@ -208,6 +261,29 @@ class UserValuationDetail(ValuationAssessmentBase):
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
@field_validator('report_url', 'certificate_url', mode='before')
|
||||
@classmethod
|
||||
def _to_list(cls, v):
|
||||
def clean(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith('`') and s.endswith('`'):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [clean(str(i)) for i in v if i is not None and str(i).strip() != ""]
|
||||
if isinstance(v, str):
|
||||
s = clean(v)
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _fill_downloads(self):
|
||||
self.report_download_urls = list(self.report_url or [])
|
||||
self.certificate_download_urls = list(self.certificate_url or [])
|
||||
return self
|
||||
|
||||
|
||||
class UserValuationList(BaseModel):
|
||||
"""用户端估值评估列表模型"""
|
||||
|
||||
@ -3,7 +3,7 @@ from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email import encoders
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple
|
||||
import httpx
|
||||
|
||||
from app.settings.config import settings
|
||||
@ -27,6 +27,14 @@ class EmailClient:
|
||||
part.add_header("Content-Disposition", f"attachment; filename=\"{file_name}\"")
|
||||
msg.attach(part)
|
||||
|
||||
if hasattr(self, "_extra_attachments") and isinstance(self._extra_attachments, list):
|
||||
for fb, fn in self._extra_attachments:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(fb)
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", f"attachment; filename=\"{fn}\"")
|
||||
msg.attach(part)
|
||||
|
||||
if settings.SMTP_TLS:
|
||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30)
|
||||
server.starttls()
|
||||
@ -45,5 +53,12 @@ class EmailClient:
|
||||
pass
|
||||
return {"status": "FAIL", "error": str(e)}
|
||||
|
||||
def send_many(self, to_email: str, subject: Optional[str], body: str, attachments: Optional[List[Tuple[bytes, str]]] = None) -> dict:
|
||||
self._extra_attachments = attachments or []
|
||||
try:
|
||||
return self.send(to_email, subject, body, None, None, None)
|
||||
finally:
|
||||
self._extra_attachments = []
|
||||
|
||||
|
||||
email_client = EmailClient()
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
BIN
app/static/images/7-icon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
@ -20,6 +20,15 @@ server {
|
||||
root /opt/vue-fastapi-admin/web/dist;
|
||||
index index.html index.htm;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
# PC 前端(/pc/ 前缀)
|
||||
location = /pc {
|
||||
return 302 /pc/;
|
||||
}
|
||||
location ^~ /pc/ {
|
||||
alias /opt/vue-fastapi-admin/web1/dist/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location ^~ /api/ {
|
||||
proxy_pass http://127.0.0.1:9999;
|
||||
|
||||