209 lines
10 KiB
Python
209 lines
10 KiB
Python
"""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}"
|