"""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("\n\n\n") html.append(" \n") html.append(f" 报价单 - {quote.get('quoteNo', '')}\n") html.append(f" \n\n\n") html.append("
\n") html.append("

产品报价单

\n") html.append("
山峡化工 CRM
\n") html.append("
\n") html.append("
\n") html.append(" \n") html.append(f" ") html.append(f"\n") html.append(f" ") html.append(f"\n") html.append("
报价单号:{quote.get('quoteNo', '')}版本号:{quote.get('version', '')}
报价日期:{_format_date(quote.get('quoteDate'))}有效期至:{_format_date(quote.get('expireDate'))}
\n
\n") html.append("
\n

客户信息

\n") html.append(" \n") html.append(f" \n") html.append(f" ") html.append(f"\n") html.append(f" \n") html.append("
客户名称:{quote.get('customerName', '')}
联系人:{_default_value(quote.get('contactName'))}联系电话:{_default_value(quote.get('contactMobile'))}
电子邮箱:{_default_value(quote.get('contactEmail'))}
\n
\n") html.append("
\n

产品明细

\n") html.append(" \n") html.append(" ") html.append("") html.append("") html.append("\n") html.append(" \n") for idx, product in enumerate(products or [], start=1): html.append(" \n") html.append(f" \n") html.append(f" \n") html.append(f" \n") html.append(f" \n") html.append(f" \n") html.append(f" \n") html.append(f" \n") html.append(f" \n") html.append(" \n") html.append(" \n
序号产品名称规格型号单位数量单价(元)折扣小计(元)
{idx}{product.get('productName', '')}{_default_value(product.get('model'))}{product.get('unit', '')}{product.get('quantity', '')}{_format_money(product.get('price'))}{_format_discount(product.get('discount'))}{_format_money(product.get('subtotal'))}
\n
\n") html.append("
\n \n") html.append(f" \n") discount_rate = quote.get("discountRate") if discount_rate is not None and Decimal(str(discount_rate)) > Decimal("0"): html.append( f" " f"\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' \n') html.append( f' \n' ) # 最终金额 = 产品最终价 + 运费 + 桶押金(运费/桶押金不计入存储的 finalAmount) final_amount = Decimal(str(quote.get("finalAmount") or 0)) + freight + barrel_deposit html.append( f" " f"\n" ) html.append("
产品总价:¥ {_format_money(quote.get('totalAmount'))}
整单折扣({discount_rate}%):- ¥ {_format_money(quote.get('discountAmount'))}
运费(元):{freight_text}
桶押金(元):{barrel_deposit_text}
最终金额:¥ {_format_money(final_amount)}
\n
\n") html.append("
\n") if (quote.get("paymentTerms") or "").strip(): html.append(f"

付款条款

\n

{quote.get('paymentTerms')}

\n") if (quote.get("deliveryTerms") or "").strip(): html.append(f"

交付条款

\n

{quote.get('deliveryTerms')}

\n") if (quote.get("remark") or "").strip(): html.append(f"

备注说明

\n

{quote.get('remark')}

\n") html.append("
\n") html.append("
\n") html.append(f"

销售负责人: {_default_value(quote.get('ownerUserName'))}

\n") html.append(f"

本报价单有效期至 {_format_date(quote.get('expireDate'))},过期需重新确认价格。

\n") html.append("
\n\n") 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}"