Files
shanxia2.0/backend/app/api/crm_quote_routes.py
T
wuzilongandCursor 6a8cd90b98 feat: cascade column facet options by current list context
Make dynamic column-facet values respect other active filters (Excel-style), with backend facet context helpers, list-page wiring, slip print/sidebar fixes, and refreshed graphify graphs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 22:04:20 +08:00

800 lines
29 KiB
Python

"""CRM 报价单 API 路由 — 挂接到 crm.router"""
from __future__ import annotations
from datetime import datetime
from fastapi import Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
from app.core.response import error_response, success_response
from app.core.tenant_context import get_tenant_no
from app.crud.crm.data_scope_helpers import crm_user_info
from app.crud.crm.quote import (
accept_quote,
create_new_version,
create_quote,
generate_pdf,
generate_share,
get_quote_detail,
get_template,
invalid_quote,
list_quote_column_facet_values,
list_quotes,
query_versions,
reject_quote,
save_template,
soft_delete_quotes,
statistics_overview,
statistics_status,
statistics_trend,
submit_quote,
to_contract,
total_pages as quote_total_pages,
update_quote,
)
from app.crud.crm.quote_approver_setting import get_setting, save_setting
from app.crud.crm.quote_approval import (
approve_quote,
list_quote_approvals,
refresh_approver_names,
withdraw_approval,
)
from app.crud.crm.quote_product import (
insert_quote_product,
list_quote_products,
soft_delete_quote_products,
update_quote_product,
)
from app.crud.crm.quote_share_log import list_share_logs, share_log_statistics
from app.schemas.crm.quote_approver_setting import QuoteApproverSettingSaveBody
from app.schemas.crm.quote import (
QuoteAcceptBody,
QuoteApprovalApproveBody,
QuoteApprovalListBody,
QuoteApprovalRefreshNamesBody,
QuoteApprovalWithdrawBody,
QuoteColumnFacetBody,
QuoteCreateBody,
QuoteDeleteBody,
QuoteDetailBody,
QuoteInvalidBody,
QuoteListBody,
QuoteNewVersionBody,
QuotePdfBody,
QuoteProductCreateBody,
QuoteProductDeleteBody,
QuoteProductListBody,
QuoteProductUpdateBody,
QuoteRejectBody,
QuoteShareGenerateBody,
QuoteShareLogListBody,
QuoteShareLogStatisticsBody,
QuoteStatisticsOverviewBody,
QuoteStatisticsTrendBody,
QuoteSubmitBody,
QuoteTemplateGetBody,
QuoteTemplateSaveBody,
QuoteToContractBody,
QuoteUpdateBody,
QuoteVersionsBody,
)
from app.utils.crm_export_columns import QUOTE_EXPORT_COLUMNS
from app.utils.excel_export import (
EXPORT_MAX_ROWS,
ExportValidationError,
export_rows_to_xlsx_response,
filter_export_columns,
)
from app.api.crm import _filter_export_rows_by_ids
def register_quote_routes(router):
@router.post("/quote/list")
async def quote_list(
body: QuoteListBody = None,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
list_body = body or QuoteListBody()
user_id = _user.get("user_id", "") or _user.get("id", "")
rows, total = list_quotes(
db,
tenant_no,
user_info=user_info,
page=list_body.page,
page_size=list_body.pageSize,
filters=list_body.model_dump(by_alias=True, exclude={"page", "pageSize"}),
current_user_id=user_id,
)
tp = quote_total_pages(total, list_body.pageSize)
return success_response(
{
"list": rows,
"rows": rows,
"total": total,
"page": list_body.page,
"pageSize": list_body.pageSize,
"totalPages": tp,
}
)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/column-facet-values")
async def quote_column_facet_values(
body: QuoteColumnFacetBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
"""报价列表列头可选值(单号/客户去重;Excel 级联上下文)"""
try:
from app.services.crm.facet_context import facet_filter_kwargs_from_body
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
filter_kwargs = facet_filter_kwargs_from_body(body, body.field)
rows = list_quote_column_facet_values(
db,
tenant_no,
field=body.field,
keyword=body.keyword,
limit=body.limit if body.limit is not None else 500,
user_info=user_info,
filter_kwargs=filter_kwargs,
)
return success_response({"list": rows})
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/detail")
async def quote_detail(
body: QuoteDetailBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
detail = get_quote_detail(db, body.id, tenant_no=tenant_no, user_info=user_info)
return success_response(detail)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/add")
async def quote_add(
body: QuoteCreateBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
user_id = _user.get("user_id", "") or _user.get("id", "")
dept_id = _user.get("dept_id", "") or _user.get("create_dept_id", "") or ""
detail = create_quote(
db,
tenant_no=tenant_no,
data=body.model_dump(by_alias=True),
user_id=user_id,
dept_id=dept_id,
)
return success_response(detail, message="新增成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/update")
async def quote_update(
body: QuoteUpdateBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
user_id = _user.get("user_id", "") or _user.get("id", "")
dept_id = _user.get("dept_id", "") or _user.get("create_dept_id", "") or ""
data = body.model_dump(by_alias=True)
quote_id = data.pop("id")
detail = update_quote(
db,
quote_id,
tenant_no=tenant_no,
data=data,
user_id=user_id,
dept_id=dept_id,
user_info=user_info,
)
return success_response(detail, message="修改成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/delete")
async def quote_delete(
body: QuoteDeleteBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
soft_delete_quotes(db, body.ids, tenant_no=tenant_no, user_info=user_info)
return success_response(None, message="删除成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/submit")
async def quote_submit(
body: QuoteSubmitBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
submit_quote(
db,
body.id,
tenant_no=tenant_no,
approver_ids=body.approverIds,
remark=body.remark,
user_info=user_info,
)
return success_response(None, message="提交成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/saveTemplate")
async def quote_save_template(
body: QuoteTemplateSaveBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
save_template(db, body.id, body.templateContent, tenant_no=tenant_no)
return success_response(None, message="保存成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/getTemplate")
async def quote_get_template(
body: QuoteTemplateGetBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
content = get_template(db, body.id, tenant_no=tenant_no, user_info=user_info)
return success_response(content or "")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/generatePdf")
async def quote_generate_pdf(
body: QuotePdfBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
pdf_url = generate_pdf(db, body.id, tenant_no=tenant_no)
return success_response(pdf_url)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/generateShare")
async def quote_generate_share(
body: QuoteShareGenerateBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
link = generate_share(
db,
body.id,
tenant_no=tenant_no,
validity_days=body.validityDays,
password=body.password or "",
user_info=user_info,
)
return success_response(link)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/accept")
async def quote_accept(
body: QuoteAcceptBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
accept_quote(db, body.id, tenant_no=tenant_no, remark=body.remark)
return success_response(None, message="操作成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/reject")
async def quote_reject(
body: QuoteRejectBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
reject_quote(
db,
body.id,
tenant_no=tenant_no,
reject_reason=body.rejectReason,
remark=body.remark,
user_info=user_info,
)
return success_response(None, message="操作成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/toContract")
async def quote_to_contract(
body: QuoteToContractBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
user_id = _user.get("user_id", "") or _user.get("id", "")
dept_id = _user.get("dept_id", "") or _user.get("create_dept_id", "") or ""
contract_id = to_contract(
db,
body.id,
tenant_no=tenant_no,
contract_no=body.contractNo,
contract_title=body.contractTitle,
sign_date=body.signDate,
start_date=body.startDate,
end_date=body.endDate,
user_id=user_id,
dept_id=dept_id,
user_info=user_info,
)
return success_response(contract_id)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/newVersion")
async def quote_new_version(
body: QuoteNewVersionBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
user_id = _user.get("user_id", "") or _user.get("id", "")
dept_id = _user.get("dept_id", "") or _user.get("create_dept_id", "") or ""
new_id = create_new_version(
db,
body.id,
tenant_no=tenant_no,
version=body.version,
remark=body.remark,
user_id=user_id,
dept_id=dept_id,
user_info=user_info,
)
return success_response(new_id)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/versions")
async def quote_versions(
body: QuoteVersionsBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
rows = query_versions(db, body.quoteNo, tenant_no=tenant_no)
return success_response(rows)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/invalid")
async def quote_invalid(
body: QuoteInvalidBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
invalid_quote(
db,
body.id,
tenant_no=tenant_no,
invalid_reason=body.invalidReason or "",
remark=body.remark or "",
user_info=user_info,
)
return success_response(None, message="作废成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/export")
async def quote_export(
body: QuoteListBody = None,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
list_body = body or QuoteListBody()
rows, _ = list_quotes(
db,
tenant_no,
user_info=user_info,
page=1,
page_size=EXPORT_MAX_ROWS,
filters=list_body.model_dump(by_alias=True, exclude={"page", "pageSize"}),
)
rows = _filter_export_rows_by_ids(rows, getattr(list_body, "ids", None))
export_cols = filter_export_columns(QUOTE_EXPORT_COLUMNS, getattr(list_body, "columns", None))
return export_rows_to_xlsx_response(
db,
tenant_no=tenant_no,
sheet_title="报价单",
columns=export_cols,
rows=rows,
filename_stem=f"报价单_{datetime.now().strftime('%Y%m%d%H%M%S')}",
include_legacy_filename=False,
)
except ExportValidationError as e:
return error_response(400, str(e))
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/statistics/overview")
async def quote_statistics_overview(
body: QuoteStatisticsOverviewBody = None,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
req = body or QuoteStatisticsOverviewBody()
data = statistics_overview(
db,
tenant_no,
owner_user_id=req.ownerUserId,
begin_time=req.beginTime,
end_time=req.endTime,
user_info=user_info,
)
return success_response(data)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/statistics/status")
async def quote_statistics_status(
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
return success_response(statistics_status(db, tenant_no))
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/statistics/trend")
async def quote_statistics_trend(
body: QuoteStatisticsTrendBody = None,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
req = body or QuoteStatisticsTrendBody()
data = statistics_trend(
db,
tenant_no,
period_type=req.type or "month",
begin_time=req.beginTime,
end_time=req.endTime,
user_info=user_info,
)
return success_response(data)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/product/list")
async def quote_product_list(
body: QuoteProductListBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
rows = list_quote_products(db, body.quoteId, tenant_no=tenant_no)
return success_response(rows)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/product/add")
async def quote_product_add(
body: QuoteProductCreateBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
user_id = _user.get("user_id", "") or _user.get("id", "")
dept_id = _user.get("dept_id", "") or _user.get("create_dept_id", "") or ""
obj = insert_quote_product(
db,
tenant_no=tenant_no,
data=body.model_dump(by_alias=True),
user_id=user_id,
dept_id=dept_id,
)
from app.crud.crm.quote_product import product_row_to_dict
return success_response(product_row_to_dict(obj), message="新增成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/product/update")
async def quote_product_update(
body: QuoteProductUpdateBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
user_id = _user.get("user_id", "") or _user.get("id", "")
data = body.model_dump(by_alias=True)
line_id = data.pop("id")
obj = update_quote_product(
db,
line_id,
tenant_no=tenant_no,
data=data,
user_id=user_id,
user_info=user_info,
)
from app.crud.crm.quote_product import product_row_to_dict
return success_response(product_row_to_dict(obj), message="修改成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/product/delete")
async def quote_product_delete(
body: QuoteProductDeleteBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
soft_delete_quote_products(db, body.ids, tenant_no=tenant_no, user_info=user_info)
return success_response(None, message="删除成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/approval/list")
async def quote_approval_list(
body: QuoteApprovalListBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
rows = list_quote_approvals(db, body.quoteId, tenant_no=tenant_no)
return success_response(rows)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/approval/approve")
async def quote_approval_approve(
body: QuoteApprovalApproveBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_id = _user.get("user_id", "") or _user.get("id", "")
approve_quote(
db,
body.id,
tenant_no=tenant_no,
approval_result=body.approvalResult,
approval_opinion=body.approvalOpinion or "",
remark=body.remark or "",
user_id=user_id,
)
return success_response(None, message="审批成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/approval/withdraw")
async def quote_approval_withdraw(
body: QuoteApprovalWithdrawBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_id = _user.get("user_id", "") or _user.get("id", "")
withdraw_approval(
db,
body.quoteId,
tenant_no=tenant_no,
remark=body.remark or "",
user_id=user_id,
)
return success_response(None, message="撤回成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/approval/refreshNames")
async def quote_approval_refresh_names(
body: QuoteApprovalRefreshNamesBody = None,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
req = body or QuoteApprovalRefreshNamesBody()
count = refresh_approver_names(db, quote_id=req.quoteId)
return success_response(count)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/shareLog/list")
async def quote_share_log_list(
body: QuoteShareLogListBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
rows = list_share_logs(db, body.quoteId, tenant_no=tenant_no)
return success_response(rows)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote/shareLog/statistics")
async def quote_share_log_statistics(
body: QuoteShareLogStatisticsBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
stats = share_log_statistics(db, body.quoteId, tenant_no=tenant_no)
return success_response(stats)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))
@router.post("/quote-approver-setting/get")
async def quote_approver_setting_get(
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
data = get_setting(db, tenant_no=tenant_no)
# 无配置时默认「启用」,避免设置页首次打开落到「停用」导致保存后无法预填
return success_response(data or {"approverIds": [], "status": "0"})
except Exception as e:
return error_response(500, str(e))
@router.post("/quote-approver-setting/save")
async def quote_approver_setting_save(
body: QuoteApproverSettingSaveBody,
_user: dict = Depends(require_menu_code("system:approverSetting")),
db: Session = Depends(get_db),
):
try:
tenant_no = get_tenant_no(db, _user)
user_id = _user.get("user_id", "") or _user.get("id", "")
dept_id = _user.get("dept_id", "") or _user.get("create_dept_id", "") or ""
data = save_setting(
db,
tenant_no=tenant_no,
approver_ids=body.approverIds or [],
status=body.status or "0",
user_id=user_id,
dept_id=dept_id,
)
return success_response(data, message="保存成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))