crm_quote_pdf.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. """CRM 报价单 PDF/HTML 生成 — 对照 CrmQuotePdfServiceImpl.java"""
  2. from __future__ import annotations
  3. import re
  4. from datetime import date, datetime
  5. from decimal import Decimal
  6. from pathlib import Path
  7. from typing import Any, Dict, List, Optional
  8. from app.core.config import settings
  9. _DATE_FMT = "%Y-%m-%d"
  10. def _default_value(value: Optional[str]) -> str:
  11. return value if (value or "").strip() else "-"
  12. def _format_date(value: Any) -> str:
  13. if not value:
  14. return ""
  15. if isinstance(value, date) and not isinstance(value, datetime):
  16. return value.strftime(_DATE_FMT)
  17. if isinstance(value, datetime):
  18. return value.strftime(_DATE_FMT)
  19. text = str(value).strip()
  20. if len(text) >= 10:
  21. return text[:10]
  22. return text
  23. def _format_money(value: Any) -> str:
  24. if value is None:
  25. return "0.00"
  26. amount = Decimal(str(value))
  27. return f"{amount:,.2f}"
  28. def _format_discount(value: Any) -> str:
  29. if value is None:
  30. return "无折扣"
  31. discount = Decimal(str(value))
  32. if discount == Decimal("0"):
  33. return "无折扣"
  34. normalized = discount.normalize()
  35. return f"{normalized}%"
  36. def _get_pdf_styles() -> str:
  37. return """
  38. * { margin: 0; padding: 0; box-sizing: border-box; }
  39. body {
  40. font-family: "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
  41. line-height: 1.6; color: #333; padding: 40px; background: #fff;
  42. }
  43. .header {
  44. text-align: center; border-bottom: 3px solid #10b981;
  45. padding-bottom: 20px; margin-bottom: 30px;
  46. }
  47. .header h1 { font-size: 32px; color: #10b981; margin-bottom: 10px; }
  48. .company-name { font-size: 16px; color: #666; }
  49. .quote-info, .customer-info { margin-bottom: 30px; }
  50. .info-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
  51. .info-table td { padding: 8px 12px; border: 1px solid #e8e8e8; }
  52. .info-table td:nth-child(odd) { background: #fafafa; width: 120px; }
  53. .products { margin-bottom: 30px; }
  54. h3 {
  55. font-size: 18px; color: #10b981; margin-bottom: 15px;
  56. border-left: 4px solid #10b981; padding-left: 10px;
  57. }
  58. .product-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
  59. .product-table th {
  60. background: #10b981; color: #fff; padding: 12px 8px;
  61. text-align: center; font-weight: normal;
  62. }
  63. .product-table td { padding: 10px 8px; border: 1px solid #e8e8e8; }
  64. .center { text-align: center; }
  65. .right { text-align: right; }
  66. .price-summary { margin: 30px 0; float: right; width: 350px; }
  67. .summary-table { width: 100%; border-collapse: collapse; }
  68. .summary-table td { padding: 10px 15px; border: 1px solid #e8e8e8; }
  69. .summary-table .label { background: #fafafa; width: 150px; }
  70. .summary-table .value { text-align: right; font-size: 16px; }
  71. .summary-table .total { background: #ecfdf5; }
  72. .summary-table .total .value { color: #059669; font-size: 20px; }
  73. .terms { clear: both; margin-top: 50px; }
  74. .terms p {
  75. padding: 10px 15px; background: #f5f5f5; border-radius: 4px;
  76. line-height: 1.8; margin-bottom: 10px;
  77. }
  78. .footer {
  79. margin-top: 50px; padding-top: 20px; border-top: 2px solid #e8e8e8;
  80. text-align: center; color: #666;
  81. }
  82. .footer .notice { color: #d97706; font-size: 14px; margin-top: 20px; }
  83. """
  84. def build_quote_html(quote: Dict[str, Any], products: List[Dict[str, Any]]) -> str:
  85. """Java: buildQuoteHtml"""
  86. html: List[str] = []
  87. html.append("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n")
  88. html.append(" <meta charset=\"UTF-8\">\n")
  89. html.append(f" <title>报价单 - {quote.get('quoteNo', '')}</title>\n")
  90. html.append(f" <style>{_get_pdf_styles()}</style>\n</head>\n<body>\n")
  91. html.append(" <div class=\"header\">\n")
  92. html.append(" <h1>产品报价单</h1>\n")
  93. html.append(" <div class=\"company-name\">山峡化工 CRM</div>\n")
  94. html.append(" </div>\n")
  95. html.append(" <div class=\"quote-info\">\n")
  96. html.append(" <table class=\"info-table\">\n")
  97. html.append(f" <tr><td><strong>报价单号:</strong></td><td>{quote.get('quoteNo', '')}</td>")
  98. html.append(f"<td><strong>版本号:</strong></td><td>{quote.get('version', '')}</td></tr>\n")
  99. html.append(f" <tr><td><strong>报价日期:</strong></td><td>{_format_date(quote.get('quoteDate'))}</td>")
  100. html.append(f"<td><strong>有效期至:</strong></td><td>{_format_date(quote.get('expireDate'))}</td></tr>\n")
  101. html.append(" </table>\n </div>\n")
  102. html.append(" <div class=\"customer-info\">\n <h3>客户信息</h3>\n")
  103. html.append(" <table class=\"info-table\">\n")
  104. html.append(f" <tr><td><strong>客户名称:</strong></td><td colspan=\"3\">{quote.get('customerName', '')}</td></tr>\n")
  105. html.append(f" <tr><td><strong>联系人:</strong></td><td>{_default_value(quote.get('contactName'))}</td>")
  106. html.append(f"<td><strong>联系电话:</strong></td><td>{_default_value(quote.get('contactMobile'))}</td></tr>\n")
  107. html.append(f" <tr><td><strong>电子邮箱:</strong></td><td colspan=\"3\">{_default_value(quote.get('contactEmail'))}</td></tr>\n")
  108. html.append(" </table>\n </div>\n")
  109. html.append(" <div class=\"products\">\n <h3>产品明细</h3>\n")
  110. html.append(" <table class=\"product-table\">\n")
  111. html.append(" <thead><tr>")
  112. html.append("<th width=\"50\">序号</th><th>产品名称</th><th width=\"100\">规格型号</th>")
  113. html.append("<th width=\"60\">单位</th><th width=\"80\">数量</th><th width=\"100\">单价(元)</th>")
  114. html.append("<th width=\"80\">折扣</th><th width=\"120\">小计(元)</th></tr></thead>\n")
  115. html.append(" <tbody>\n")
  116. for idx, product in enumerate(products or [], start=1):
  117. html.append(" <tr>\n")
  118. html.append(f" <td class=\"center\">{idx}</td>\n")
  119. html.append(f" <td>{product.get('productName', '')}</td>\n")
  120. html.append(f" <td class=\"center\">{_default_value(product.get('model'))}</td>\n")
  121. html.append(f" <td class=\"center\">{product.get('unit', '')}</td>\n")
  122. html.append(f" <td class=\"right\">{product.get('quantity', '')}</td>\n")
  123. html.append(f" <td class=\"right\">{_format_money(product.get('price'))}</td>\n")
  124. html.append(f" <td class=\"center\">{_format_discount(product.get('discount'))}</td>\n")
  125. html.append(f" <td class=\"right\">{_format_money(product.get('subtotal'))}</td>\n")
  126. html.append(" </tr>\n")
  127. html.append(" </tbody>\n </table>\n </div>\n")
  128. html.append(" <div class=\"price-summary\">\n <table class=\"summary-table\">\n")
  129. html.append(f" <tr><td class=\"label\">产品总价:</td><td class=\"value\">¥ {_format_money(quote.get('totalAmount'))}</td></tr>\n")
  130. discount_rate = quote.get("discountRate")
  131. if discount_rate is not None and Decimal(str(discount_rate)) > Decimal("0"):
  132. html.append(
  133. f" <tr><td class=\"label\">整单折扣({discount_rate}%):</td>"
  134. f"<td class=\"value\">- ¥ {_format_money(quote.get('discountAmount'))}</td></tr>\n"
  135. )
  136. html.append(
  137. f" <tr class=\"total\"><td class=\"label\"><strong>最终金额:</strong></td>"
  138. f"<td class=\"value\"><strong>¥ {_format_money(quote.get('finalAmount'))}</strong></td></tr>\n"
  139. )
  140. html.append(" </table>\n </div>\n")
  141. html.append(" <div class=\"terms\">\n")
  142. if (quote.get("paymentTerms") or "").strip():
  143. html.append(f" <h3>付款条款</h3>\n <p>{quote.get('paymentTerms')}</p>\n")
  144. if (quote.get("deliveryTerms") or "").strip():
  145. html.append(f" <h3>交付条款</h3>\n <p>{quote.get('deliveryTerms')}</p>\n")
  146. if (quote.get("remark") or "").strip():
  147. html.append(f" <h3>备注说明</h3>\n <p>{quote.get('remark')}</p>\n")
  148. html.append(" </div>\n")
  149. html.append(" <div class=\"footer\">\n")
  150. html.append(f" <p><strong>销售负责人:</strong> {_default_value(quote.get('ownerUserName'))}</p>\n")
  151. html.append(f" <p class=\"notice\">本报价单有效期至 {_format_date(quote.get('expireDate'))},过期需重新确认价格。</p>\n")
  152. html.append(" </div>\n</body>\n</html>")
  153. return "".join(html)
  154. def generate_quote_pdf(quote: Dict[str, Any], products: List[Dict[str, Any]]) -> str:
  155. """
  156. Java: generateQuotePdf — 生成 HTML 文件(扩展名 .pdf),返回相对 URL。
  157. 接受 quote dict + products,避免与 quote.py 循环依赖。
  158. """
  159. if not quote:
  160. raise ValueError("报价单不存在")
  161. html_content = build_quote_html(quote, products)
  162. quote_no = (quote.get("quoteNo") or "quote").strip()
  163. safe_quote_no = re.sub(r"[/\\:*?\"<>|]", "-", quote_no)
  164. file_name = f"报价单-{safe_quote_no}.pdf"
  165. date_str = date.today().strftime("%Y%m%d")
  166. relative_path = f"/quote/{date_str}/"
  167. upload_root = Path(settings.LOCAL_UPLOAD_DIR)
  168. full_dir = upload_root / "quote" / date_str
  169. full_dir.mkdir(parents=True, exist_ok=True)
  170. file_path = full_dir / file_name
  171. file_path.write_text(html_content, encoding="utf-8")
  172. url_prefix = (settings.LOCAL_STORAGE_URL or "/uploads").rstrip("/")
  173. return f"{url_prefix}{relative_path}{file_name}"