Files
shanxia2.0/backend/app/services/crm_quote_pdf.py
T
2026-08-08 20:43:48 +08:00

209 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CRM 报价单 PDF/HTML 生成 — 对照 CrmQuotePdfServiceImpl.java"""
from __future__ import annotations
import re
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, List, Optional
from app.core.config import settings
_DATE_FMT = "%Y-%m-%d"
def _default_value(value: Optional[str]) -> str:
return value if (value or "").strip() else "-"
def _format_date(value: Any) -> str:
if not value:
return ""
if isinstance(value, date) and not isinstance(value, datetime):
return value.strftime(_DATE_FMT)
if isinstance(value, datetime):
return value.strftime(_DATE_FMT)
text = str(value).strip()
if len(text) >= 10:
return text[:10]
return text
def _format_money(value: Any) -> str:
if value is None:
return "0.00"
amount = Decimal(str(value))
return f"{amount:,.2f}"
def _format_discount(value: Any) -> str:
if value is None:
return "无折扣"
discount = Decimal(str(value))
if discount == Decimal("0"):
return "无折扣"
normalized = discount.normalize()
return f"{normalized}%"
def _get_pdf_styles() -> str:
return """
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
line-height: 1.6; color: #333; padding: 40px; background: #fff;
}
.header {
text-align: center; border-bottom: 3px solid #10b981;
padding-bottom: 20px; margin-bottom: 30px;
}
.header h1 { font-size: 32px; color: #10b981; margin-bottom: 10px; }
.company-name { font-size: 16px; color: #666; }
.quote-info, .customer-info { margin-bottom: 30px; }
.info-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
.info-table td { padding: 8px 12px; border: 1px solid #e8e8e8; }
.info-table td:nth-child(odd) { background: #fafafa; width: 120px; }
.products { margin-bottom: 30px; }
h3 {
font-size: 18px; color: #10b981; margin-bottom: 15px;
border-left: 4px solid #10b981; padding-left: 10px;
}
.product-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
.product-table th {
background: #10b981; color: #fff; padding: 12px 8px;
text-align: center; font-weight: normal;
}
.product-table td { padding: 10px 8px; border: 1px solid #e8e8e8; }
.center { text-align: center; }
.right { text-align: right; }
.price-summary { margin: 30px 0; float: right; width: 350px; }
.summary-table { width: 100%; border-collapse: collapse; }
.summary-table td { padding: 10px 15px; border: 1px solid #e8e8e8; }
.summary-table .label { background: #fafafa; width: 150px; }
.summary-table .value { text-align: right; font-size: 16px; }
.summary-table .total { background: #ecfdf5; }
.summary-table .total .value { color: #059669; font-size: 20px; }
.terms { clear: both; margin-top: 50px; }
.terms p {
padding: 10px 15px; background: #f5f5f5; border-radius: 4px;
line-height: 1.8; margin-bottom: 10px;
}
.footer {
margin-top: 50px; padding-top: 20px; border-top: 2px solid #e8e8e8;
text-align: center; color: #666;
}
.footer .notice { color: #d97706; font-size: 14px; margin-top: 20px; }
"""
def build_quote_html(quote: Dict[str, Any], products: List[Dict[str, Any]]) -> str:
"""Java: buildQuoteHtml"""
html: List[str] = []
html.append("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n")
html.append(" <meta charset=\"UTF-8\">\n")
html.append(f" <title>报价单 - {quote.get('quoteNo', '')}</title>\n")
html.append(f" <style>{_get_pdf_styles()}</style>\n</head>\n<body>\n")
html.append(" <div class=\"header\">\n")
html.append(" <h1>产品报价单</h1>\n")
html.append(" <div class=\"company-name\">山峡化工 CRM</div>\n")
html.append(" </div>\n")
html.append(" <div class=\"quote-info\">\n")
html.append(" <table class=\"info-table\">\n")
html.append(f" <tr><td><strong>报价单号:</strong></td><td>{quote.get('quoteNo', '')}</td>")
html.append(f"<td><strong>版本号:</strong></td><td>{quote.get('version', '')}</td></tr>\n")
html.append(f" <tr><td><strong>报价日期:</strong></td><td>{_format_date(quote.get('quoteDate'))}</td>")
html.append(f"<td><strong>有效期至:</strong></td><td>{_format_date(quote.get('expireDate'))}</td></tr>\n")
html.append(" </table>\n </div>\n")
html.append(" <div class=\"customer-info\">\n <h3>客户信息</h3>\n")
html.append(" <table class=\"info-table\">\n")
html.append(f" <tr><td><strong>客户名称:</strong></td><td colspan=\"3\">{quote.get('customerName', '')}</td></tr>\n")
html.append(f" <tr><td><strong>联系人:</strong></td><td>{_default_value(quote.get('contactName'))}</td>")
html.append(f"<td><strong>联系电话:</strong></td><td>{_default_value(quote.get('contactMobile'))}</td></tr>\n")
html.append(f" <tr><td><strong>电子邮箱:</strong></td><td colspan=\"3\">{_default_value(quote.get('contactEmail'))}</td></tr>\n")
html.append(" </table>\n </div>\n")
html.append(" <div class=\"products\">\n <h3>产品明细</h3>\n")
html.append(" <table class=\"product-table\">\n")
html.append(" <thead><tr>")
html.append("<th width=\"50\">序号</th><th>产品名称</th><th width=\"100\">规格型号</th>")
html.append("<th width=\"60\">单位</th><th width=\"80\">数量</th><th width=\"100\">单价(元)</th>")
html.append("<th width=\"80\">折扣</th><th width=\"120\">小计(元)</th></tr></thead>\n")
html.append(" <tbody>\n")
for idx, product in enumerate(products or [], start=1):
html.append(" <tr>\n")
html.append(f" <td class=\"center\">{idx}</td>\n")
html.append(f" <td>{product.get('productName', '')}</td>\n")
html.append(f" <td class=\"center\">{_default_value(product.get('model'))}</td>\n")
html.append(f" <td class=\"center\">{product.get('unit', '')}</td>\n")
html.append(f" <td class=\"right\">{product.get('quantity', '')}</td>\n")
html.append(f" <td class=\"right\">{_format_money(product.get('price'))}</td>\n")
html.append(f" <td class=\"center\">{_format_discount(product.get('discount'))}</td>\n")
html.append(f" <td class=\"right\">{_format_money(product.get('subtotal'))}</td>\n")
html.append(" </tr>\n")
html.append(" </tbody>\n </table>\n </div>\n")
html.append(" <div class=\"price-summary\">\n <table class=\"summary-table\">\n")
html.append(f" <tr><td class=\"label\">产品总价:</td><td class=\"value\">¥ {_format_money(quote.get('totalAmount'))}</td></tr>\n")
discount_rate = quote.get("discountRate")
if discount_rate is not None and Decimal(str(discount_rate)) > Decimal("0"):
html.append(
f" <tr><td class=\"label\">整单折扣({discount_rate}%):</td>"
f"<td class=\"value\">- ¥ {_format_money(quote.get('discountAmount'))}</td></tr>\n"
)
freight = Decimal(str(quote.get("freight") or 0))
barrel_deposit = Decimal(str(quote.get("barrelDeposit") or quote.get("barrel_deposit") or 0))
freight_text = f"¥ {_format_money(freight)}" if freight > 0 else "-"
barrel_deposit_text = f"¥ {_format_money(barrel_deposit)}" if barrel_deposit > 0 else "-"
html.append(f' <tr><td class="label">运费(元):</td><td class="value">{freight_text}</td></tr>\n')
html.append(
f' <tr><td class="label">桶押金(元):</td><td class="value">{barrel_deposit_text}</td></tr>\n'
)
# 最终金额 = 产品最终价 + 运费 + 桶押金(运费/桶押金不计入存储的 finalAmount)
final_amount = Decimal(str(quote.get("finalAmount") or 0)) + freight + barrel_deposit
html.append(
f" <tr class=\"total\"><td class=\"label\"><strong>最终金额:</strong></td>"
f"<td class=\"value\"><strong>¥ {_format_money(final_amount)}</strong></td></tr>\n"
)
html.append(" </table>\n </div>\n")
html.append(" <div class=\"terms\">\n")
if (quote.get("paymentTerms") or "").strip():
html.append(f" <h3>付款条款</h3>\n <p>{quote.get('paymentTerms')}</p>\n")
if (quote.get("deliveryTerms") or "").strip():
html.append(f" <h3>交付条款</h3>\n <p>{quote.get('deliveryTerms')}</p>\n")
if (quote.get("remark") or "").strip():
html.append(f" <h3>备注说明</h3>\n <p>{quote.get('remark')}</p>\n")
html.append(" </div>\n")
html.append(" <div class=\"footer\">\n")
html.append(f" <p><strong>销售负责人:</strong> {_default_value(quote.get('ownerUserName'))}</p>\n")
html.append(f" <p class=\"notice\">本报价单有效期至 {_format_date(quote.get('expireDate'))},过期需重新确认价格。</p>\n")
html.append(" </div>\n</body>\n</html>")
return "".join(html)
def generate_quote_pdf(quote: Dict[str, Any], products: List[Dict[str, Any]]) -> str:
"""
Java: generateQuotePdf — 生成 HTML 文件(扩展名 .pdf),返回相对 URL。
接受 quote dict + products,避免与 quote.py 循环依赖。
"""
if not quote:
raise ValueError("报价单不存在")
html_content = build_quote_html(quote, products)
quote_no = (quote.get("quoteNo") or "quote").strip()
safe_quote_no = re.sub(r"[/\\:*?\"<>|]", "-", quote_no)
file_name = f"报价单-{safe_quote_no}.pdf"
date_str = date.today().strftime("%Y%m%d")
relative_path = f"/quote/{date_str}/"
upload_root = Path(settings.LOCAL_UPLOAD_DIR)
full_dir = upload_root / "quote" / date_str
full_dir.mkdir(parents=True, exist_ok=True)
file_path = full_dir / file_name
file_path.write_text(html_content, encoding="utf-8")
url_prefix = (settings.LOCAL_STORAGE_URL or "/uploads").rstrip("/")
return f"{url_prefix}{relative_path}{file_name}"