"""CRM 报价单 CRUD — 对照 CrmQuoteServiceImpl.java(P2 全量)""" from __future__ import annotations import math import secrets from collections import defaultdict from datetime import date, datetime, timedelta from decimal import Decimal, ROUND_HALF_UP from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import exists, func, or_ from sqlalchemy.orm import Query, Session, aliased from app.core.delete_guard import assert_row_deletable_by_creator from app.core.utils import generate_uuid from app.crud.crm.contact import get_contact from app.crud.crm.customer import get_customer from app.crud.crm.opportunity import get_opportunity from app.crud.crm.product import resolve_product_id_for_quote_item from app.services.crm_supplier_hazard import assert_products_allowed_for_supplier from app.crud.crm.quote_approval import ( can_user_approve, create_approval_records, delete_pending_approvals, ) from app.crud.crm.quote_product import ( batch_insert_quote_products, delete_products_by_quote_id, list_products_by_quote_ids, list_quote_products, ) from app.crud.crm.quote_share_log import record_visit from app.crud.crm.supplier_info import get_supplier_info from app.models.crm_models import CrmContract, CrmCustomer, CrmQuote, CrmQuoteApproval from app.models.models import SysUser from app.services.crm_quote_pdf import generate_quote_pdf from app.crud.crm.data_scope_helpers import apply_crm_owner_scope, ensure_crm_owner_row_in_scope _FMT = "%Y-%m-%d %H:%M:%S" _DATE_FMT = "%Y-%m-%d" def _ensure_quote_in_scope(db: Session, row: CrmQuote, user_info, tenant_no: str) -> None: ensure_crm_owner_row_in_scope(db, row, user_info, tenant_no, message="无权访问该报价单") def _get_quote_in_scope( db: Session, quote_id: str, *, tenant_no: str, user_info=None, ) -> CrmQuote: obj = get_quote(db, quote_id, tenant_no=tenant_no) if not obj: raise ValueError("报价单不存在") _ensure_quote_in_scope(db, obj, user_info, tenant_no) return obj def total_pages(total: int, page_size: int) -> int: if page_size <= 0: return 0 return math.ceil(total / page_size) def _decimal_value(value: Any, default: Decimal = Decimal("0")) -> Decimal: if value is None or value == "": return default if isinstance(value, Decimal): return value return Decimal(str(value)) def _parse_optional_date(value: Any) -> Optional[date]: if value is None: return None if isinstance(value, date) and not isinstance(value, datetime): return value if isinstance(value, datetime): return value.date() text = str(value).strip() if not text: return None for fmt in (_DATE_FMT, _FMT): try: return datetime.strptime(text, fmt).date() except ValueError: continue raise ValueError(f"日期格式无效: {text}") def _parse_optional_datetime(value: Any) -> Optional[datetime]: if value is None: return None if isinstance(value, datetime): return value text = str(value).strip() if not text: return None for fmt in (_FMT, _DATE_FMT): try: return datetime.strptime(text, fmt) except ValueError: continue raise ValueError(f"日期时间格式无效: {text}") def _get_field(data: dict, *keys: str, default=None): for key in keys: if key in data and data[key] is not None: return data[key] return default def _resolve_user_display_name(db: Session, user_id: Optional[str]) -> str: uid = (user_id or "").strip() if not uid: return "" u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first() if not u: return "" return (u.nick_name or u.user_no or "").strip() def _row_to_dict(row: CrmQuote) -> Dict[str, Any]: """ORM → camelCase(单点真相)""" return { "id": row.id, "quoteNo": row.quote_no or "", "quoteTitle": row.quote_title or "", "quoteType": row.quote_type or "sales", "customerId": row.customer_id or "", "customerName": row.customer_name or "", "opportunityId": row.opportunity_id or "", "contactId": row.contact_id or "", "contactName": row.contact_name or "", "contactMobile": row.contact_mobile or "", "contactEmail": row.contact_email or "", "version": row.version or "V1.0", "parentId": row.parent_id or "0", "isLatest": row.is_latest if row.is_latest is not None else 1, "totalAmount": float(row.total_amount) if row.total_amount is not None else 0.0, "discountRate": float(row.discount_rate) if row.discount_rate is not None else 0.0, "discountAmount": float(row.discount_amount) if row.discount_amount is not None else 0.0, "finalAmount": float(row.final_amount) if row.final_amount is not None else 0.0, "validityDays": row.validity_days if row.validity_days is not None else 30, "quoteDate": row.quote_date.strftime(_DATE_FMT) if row.quote_date else "", "expireDate": row.expire_date.strftime(_DATE_FMT) if row.expire_date else "", "status": row.status or "draft", "rejectReason": row.reject_reason or "", "pdfUrl": row.pdf_url or "", "pdfGenerated": row.pdf_generated if row.pdf_generated is not None else 0, "shareLink": row.share_link or "", "shareCode": row.share_code or "", "sharePassword": row.share_password or "", "shareExpireTime": row.share_expire_time.strftime(_FMT) if row.share_expire_time else "", "viewCount": row.view_count if row.view_count is not None else 0, "lastViewTime": row.last_view_time.strftime(_FMT) if row.last_view_time else "", "ownerUserId": row.owner_user_id or "", "ownerUserName": row.owner_user_name or "", "supplierId": row.supplier_id or "", "supplierName": row.supplier_name or "", "contractId": row.contract_id or "", "paymentTerms": row.payment_terms or "", "deliveryTerms": row.delivery_terms or "", "freight": float(row.freight) if row.freight is not None else 0.0, "barrelDeposit": float(getattr(row, "barrel_deposit", None) or 0), "attachments": row.attachments or "", "templateContent": row.template_content or "", "createBy": row.create_by or "", "createTime": row.create_time.strftime(_FMT) if row.create_time else "", "updateTime": row.update_time.strftime(_FMT) if row.update_time else "", "remark": row.remark or "", } def quote_row_to_dict(row: CrmQuote) -> Dict[str, Any]: return _row_to_dict(row) def _build_quote_filters( db: Session, q: Query, filters: dict, *, tenant_no: str, ) -> Query: """Java: buildQueryWrapper""" q = q.filter(CrmQuote.tenant_no == tenant_no, CrmQuote.del_flag == 0) quote_no = (_get_field(filters, "quote_no", "quoteNo") or "").strip() if quote_no: q = q.filter(CrmQuote.quote_no.like(f"%{quote_no}%")) keyword = (_get_field(filters, "keyword") or "").strip() quote_title = (_get_field(filters, "quote_title", "quoteTitle") or "").strip() customer_name = (_get_field(filters, "customer_name", "customerName") or "").strip() if keyword: # 报价编号 / 报价主题 / 客户名称 / 客户名称拼音首字母 like = f"%{keyword}%" pinyin_like = f"%{keyword.lower()}%" customer_pinyin_match = exists().where( CrmCustomer.id == CrmQuote.customer_id, CrmCustomer.del_flag == 0, CrmCustomer.tenant_no == tenant_no, func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like(pinyin_like), ) q = q.filter( or_( CrmQuote.quote_no.like(like), CrmQuote.quote_title.like(like), CrmQuote.customer_name.like(like), customer_pinyin_match, ) ) elif quote_title and customer_name and quote_title == customer_name: q = q.filter( or_( CrmQuote.quote_title.like(f"%{quote_title}%"), CrmQuote.customer_name.like(f"%{customer_name}%"), ) ) else: if quote_title: q = q.filter(CrmQuote.quote_title.like(f"%{quote_title}%")) if customer_name: q = q.filter(CrmQuote.customer_name.like(f"%{customer_name}%")) customer_id = (_get_field(filters, "customer_id", "customerId") or "").strip() if customer_id: q = q.filter(CrmQuote.customer_id == customer_id) opportunity_id = (_get_field(filters, "opportunity_id", "opportunityId") or "").strip() if opportunity_id: q = q.filter(CrmQuote.opportunity_id == opportunity_id) status = (_get_field(filters, "status") or "").strip() if status: q = q.filter(CrmQuote.status == status) exclude_status = (_get_field(filters, "exclude_status", "excludeStatus") or "").strip() if exclude_status: q = q.filter(CrmQuote.status != exclude_status) quote_type = (_get_field(filters, "quote_type", "quoteType") or "").strip() if quote_type: q = q.filter(CrmQuote.quote_type == quote_type) owner_user_id = (_get_field(filters, "owner_user_id", "ownerUserId") or "").strip() if owner_user_id: q = q.filter(CrmQuote.owner_user_id == owner_user_id) is_latest = _get_field(filters, "is_latest", "isLatest") if is_latest is not None and is_latest != "": q = q.filter(CrmQuote.is_latest == int(is_latest)) min_amount = _get_field(filters, "min_amount", "minAmount") if min_amount is not None and min_amount != "": q = q.filter(CrmQuote.final_amount >= _decimal_value(min_amount)) max_amount = _get_field(filters, "max_amount", "maxAmount") if max_amount is not None and max_amount != "": q = q.filter(CrmQuote.final_amount <= _decimal_value(max_amount)) quote_date_begin = _parse_optional_date(_get_field(filters, "quote_date_begin", "quoteDateBegin")) if quote_date_begin: q = q.filter(CrmQuote.quote_date >= quote_date_begin) quote_date_end = _parse_optional_date(_get_field(filters, "quote_date_end", "quoteDateEnd")) if quote_date_end: q = q.filter(CrmQuote.quote_date <= quote_date_end) begin_time = _parse_optional_datetime(_get_field(filters, "begin_time", "beginTime")) end_time = _parse_optional_datetime(_get_field(filters, "end_time", "endTime")) if begin_time and end_time: q = q.filter(CrmQuote.create_time >= begin_time, CrmQuote.create_time <= end_time) elif begin_time: q = q.filter(CrmQuote.create_time >= begin_time) elif end_time: q = q.filter(CrmQuote.create_time <= end_time) pending_approval_user_id = ( _get_field(filters, "pending_approval_user_id", "pendingApprovalUserId") or "" ).strip() if pending_approval_user_id: # 待审批 Tab:仅返回当前轮到该用户审批的报价单(顺序审批,前序步骤均已通过) q = q.filter(CrmQuote.status == "submitted") user_approval = aliased(CrmQuoteApproval) prior_approval = aliased(CrmQuoteApproval) q = q.filter( exists().where( user_approval.quote_id == CrmQuote.id, user_approval.approver_id == pending_approval_user_id, user_approval.approval_result == "pending", user_approval.del_flag == 0, ~exists().where( prior_approval.quote_id == user_approval.quote_id, prior_approval.step < user_approval.step, prior_approval.approval_result != "approved", prior_approval.del_flag == 0, ), ) ) approved_by_user_id = (_get_field(filters, "approved_by_user_id", "approvedByUserId") or "").strip() if approved_by_user_id: q = q.filter( CrmQuote.id.in_( db.query(CrmQuoteApproval.quote_id) .filter( CrmQuoteApproval.approver_id == approved_by_user_id, CrmQuoteApproval.approval_result.in_(("approved", "rejected")), CrmQuoteApproval.del_flag == 0, ) .distinct() ) ) return q.order_by(CrmQuote.create_time.desc()) def _fill_contract_linked_count(db: Session, rows: List[Dict[str, Any]]) -> None: """Java: fillContractLinkedCount""" quote_ids = {str(d.get("id") or "").strip() for d in rows if d.get("id")} quote_ids.discard("") if not quote_ids: return contracts = ( db.query(CrmContract.quote_id) .filter( CrmContract.quote_id.in_(quote_ids), CrmContract.del_flag == 0, ) .all() ) count_map: Dict[str, int] = defaultdict(int) for (qid,) in contracts: if qid: count_map[qid] += 1 for d in rows: d["contractLinkedCount"] = count_map.get(str(d.get("id") or ""), 0) def calculate_quote_amount( entity: CrmQuote, products: List[dict], ) -> None: """Java: calculateAmount""" if not products: entity.total_amount = Decimal("0") entity.discount_amount = Decimal("0") entity.final_amount = Decimal("0") return total_amount = Decimal("0") for product in products: price = _decimal_value(_get_field(product, "price")) quantity = _decimal_value(_get_field(product, "quantity")) discount = _decimal_value(_get_field(product, "discount"), Decimal("0")) subtotal = price * quantity * (Decimal("1") - discount / Decimal("100")) total_amount += subtotal entity.total_amount = total_amount discount_rate = _decimal_value(entity.discount_rate, Decimal("0")) discount_amount = total_amount * discount_rate / Decimal("100") entity.discount_amount = discount_amount entity.final_amount = total_amount - discount_amount def _resolve_quote_product_ids( db: Session, *, tenant_no: str, products: List[dict], user_id: str = None, ) -> None: """Java: resolveQuoteProductIds""" if not products: return for product in products: pid = resolve_product_id_for_quote_item( db, tenant_no=tenant_no, product_data=product, user_id=user_id, ) product["productId"] = pid product["product_id"] = pid def generate_quote_no( db: Session, tenant_no: str, *, supplier_id: str = None, quote_date: date = None, ) -> str: """Java: generateQuoteNo — {供方编码}DD-yyyyMMdd + 3位序号""" supplier_code = "SX" if supplier_id: supplier = get_supplier_info(db, supplier_id, tenant_no=tenant_no) if supplier and (supplier.supplier_code or "").strip(): supplier_code = supplier.supplier_code.strip() date_to_use = quote_date or date.today() date_str = date_to_use.strftime("%Y%m%d") prefix = f"{supplier_code}DD-{date_str}" last = ( db.query(CrmQuote) .filter( CrmQuote.tenant_no == tenant_no, CrmQuote.del_flag == 0, CrmQuote.quote_no.like(f"{prefix}%"), CrmQuote.quote_date == date_to_use, ) .order_by(CrmQuote.quote_no.desc()) .first() ) sequence = 1 if last and last.quote_no and len(last.quote_no) >= len(prefix) + 3: try: sequence = int(last.quote_no[len(prefix) :]) + 1 except ValueError: sequence = 1 for retry in range(10): candidate = f"{prefix}{sequence + retry:03d}" exists = ( db.query(CrmQuote.id) .filter(CrmQuote.quote_no == candidate, CrmQuote.del_flag == 0) .first() ) if not exists: return candidate raise ValueError("生成报价单编号失败,请稍后重试") def generate_quote_title( quote_no: str, quote_type: str, supplier_short_name: str, customer_short_name: str, ) -> str: """Java: 自动生成报价主题""" if quote_type == "purchase": return f"{customer_short_name}-{supplier_short_name}({quote_no})" return f"{supplier_short_name}-{customer_short_name}({quote_no})" def _get_supplier_short_name(db: Session, supplier_id: str, tenant_no: str) -> str: if not supplier_id: return "山峡" supplier = get_supplier_info(db, supplier_id, tenant_no=tenant_no) if not supplier: return "山峡" if (supplier.short_name or "").strip(): return supplier.short_name.strip() if (supplier.supplier_name or "").strip(): return supplier.supplier_name.strip() return "山峡" def _enrich_contact_fields(db: Session, entity: CrmQuote) -> None: if not entity.contact_id: return contact = get_contact(db, entity.contact_id) if contact: entity.contact_mobile = contact.mobile or entity.contact_mobile or "" entity.contact_email = contact.email or entity.contact_email or "" if not entity.contact_name: entity.contact_name = contact.contact_name or "" def _enrich_supplier_name(db: Session, entity: CrmQuote, tenant_no: str) -> None: if not entity.supplier_id: return supplier = get_supplier_info(db, entity.supplier_id, tenant_no=tenant_no) if supplier: entity.supplier_name = supplier.supplier_name or "" def get_quote( db: Session, quote_id: str, *, tenant_no: str = None, ) -> Optional[CrmQuote]: q = db.query(CrmQuote).filter(CrmQuote.id == quote_id, CrmQuote.del_flag == 0) if tenant_no: q = q.filter(CrmQuote.tenant_no == tenant_no) return q.first() def get_quote_detail( db: Session, quote_id: str, *, tenant_no: str = None, user_info=None, ) -> Dict[str, Any]: """Java: queryById""" obj = get_quote(db, quote_id, tenant_no=tenant_no) if not obj: raise ValueError("报价单不存在") _ensure_quote_in_scope(db, obj, user_info, tenant_no or obj.tenant_no) detail = _row_to_dict(obj) detail["productList"] = list_quote_products(db, quote_id, tenant_no=tenant_no or obj.tenant_no) if obj.opportunity_id: opp = get_opportunity(db, obj.opportunity_id, tenant_no=tenant_no or obj.tenant_no) if opp: detail["opportunityName"] = opp.get("opportunityName") or "" if obj.contact_id and (not detail.get("contactMobile") or not detail.get("contactEmail")): contact = get_contact(db, obj.contact_id) if contact: if not detail.get("contactMobile"): detail["contactMobile"] = contact.mobile or "" if not detail.get("contactEmail"): detail["contactEmail"] = contact.email or "" if obj.owner_user_id and not detail.get("ownerUserMobile"): owner = db.query(SysUser).filter(SysUser.id == obj.owner_user_id, SysUser.del_flag == 0).first() if owner and owner.phone_no: detail["ownerUserMobile"] = owner.phone_no if obj.supplier_id: supplier = get_supplier_info(db, obj.supplier_id, tenant_no=tenant_no or obj.tenant_no) if supplier: if supplier.supplier_name: detail["supplierName"] = supplier.supplier_name detail["supplierCode"] = supplier.supplier_code or "" detail["supplierShortName"] = supplier.short_name or "" return detail def list_quotes( db: Session, tenant_no: str, *, page: int = 1, page_size: int = 10, filters: dict = None, current_user_id: str = None, user_info=None, ) -> Tuple[List[Dict[str, Any]], int]: """Java: queryPageList""" q = db.query(CrmQuote) q = _build_quote_filters(db, q, filters or {}, tenant_no=tenant_no) if user_info: q = apply_crm_owner_scope(q, CrmQuote, user_info, db, tenant_no) total = q.order_by(None).with_entities(func.count(CrmQuote.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = q.offset((page - 1) * page_size).limit(page_size).all() result = [_row_to_dict(r) for r in rows] for item in result: if item.get("status") == "submitted" and current_user_id: item["canApprove"] = can_user_approve(db, item["id"], current_user_id) else: item["canApprove"] = False if result: quote_ids = [str(item["id"]) for item in result if item.get("id")] products_map = list_products_by_quote_ids(db, quote_ids, tenant_no=tenant_no) for item in result: item["productList"] = products_map.get(str(item.get("id") or ""), []) _fill_contract_linked_count(db, result) return result, int(total) def create_quote( db: Session, *, tenant_no: str, data: dict, user_id: str = None, dept_id: str = "", ) -> Dict[str, Any]: """Java: insertByBo""" products = _get_field(data, "products", "productList") or [] if not isinstance(products, list): raise ValueError("产品明细格式无效") product_rows = [dict(p) for p in products] customer_id = (_get_field(data, "customer_id", "customerId") or "").strip() if not customer_id: raise ValueError("客户ID不能为空") customer = get_customer(db, customer_id) if customer and tenant_no and customer.tenant_no != tenant_no: raise ValueError("客户不存在") supplier_id = (_get_field(data, "supplier_id", "supplierId") or "").strip() or None supplier_short_name = _get_supplier_short_name(db, supplier_id or "", tenant_no) assert_products_allowed_for_supplier( db, tenant_no=tenant_no, supplier_id=str(supplier_id) if supplier_id else None, products=product_rows or [], ) quote_date = _parse_optional_date(_get_field(data, "quote_date", "quoteDate")) or date.today() quote_type = (_get_field(data, "quote_type", "quoteType") or "sales").strip() or "sales" validity_days = int(_get_field(data, "validity_days", "validityDays") or 30) quote_no = (_get_field(data, "quote_no", "quoteNo") or "").strip() if not quote_no or "***" in quote_no: quote_no = generate_quote_no(db, tenant_no, supplier_id=supplier_id, quote_date=quote_date) customer_short_name = (customer.short_name or "") if customer else "" customer_name = (customer.customer_name or "") if customer else (_get_field(data, "customer_name", "customerName") or "") quote_title = (_get_field(data, "quote_title", "quoteTitle") or "").strip() if not quote_title: quote_title = generate_quote_title(quote_no, quote_type, supplier_short_name, customer_short_name) owner_user_id = (_get_field(data, "owner_user_id", "ownerUserId") or user_id or "").strip() owner_user_name = _resolve_user_display_name(db, owner_user_id) if owner_user_id else "" now = datetime.now() entity = CrmQuote( id=generate_uuid(), quote_no=quote_no, quote_title=quote_title, quote_type=quote_type, customer_id=customer_id, customer_name=customer_name, opportunity_id=_get_field(data, "opportunity_id", "opportunityId") or None, contact_id=_get_field(data, "contact_id", "contactId") or None, contact_name=_get_field(data, "contact_name", "contactName") or "", version=_get_field(data, "version") or "V1.0", parent_id=_get_field(data, "parent_id", "parentId") or "0", is_latest=int(_get_field(data, "is_latest", "isLatest") if _get_field(data, "is_latest", "isLatest") is not None else 1), discount_rate=_decimal_value(_get_field(data, "discount_rate", "discountRate"), Decimal("0")), validity_days=validity_days, quote_date=quote_date, expire_date=quote_date + timedelta(days=validity_days), status=_get_field(data, "status") or "draft", owner_user_id=owner_user_id or user_id or "", owner_user_name=owner_user_name, supplier_id=supplier_id, supplier_name=_get_field(data, "supplier_name", "supplierName") or "", payment_terms=_get_field(data, "payment_terms", "paymentTerms") or "", delivery_terms=_get_field(data, "delivery_terms", "deliveryTerms") or "", freight=_decimal_value(_get_field(data, "freight"), Decimal("0")), barrel_deposit=_decimal_value(_get_field(data, "barrel_deposit", "barrelDeposit"), Decimal("0")), attachments=_get_field(data, "attachments") or "", tenant_no=tenant_no, order_num=_get_field(data, "order_num", "orderNum"), remark=_get_field(data, "remark") or "", create_by=user_id, create_dept_id=dept_id or "", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) _enrich_contact_fields(db, entity) _enrich_supplier_name(db, entity, tenant_no) _resolve_quote_product_ids(db, tenant_no=tenant_no, products=product_rows, user_id=user_id) calculate_quote_amount(entity, product_rows) db.add(entity) db.flush() if product_rows: batch_insert_quote_products( db, tenant_no=tenant_no, quote_id=entity.id, products=product_rows, user_id=user_id, dept_id=dept_id, ) db.commit() db.refresh(entity) return get_quote_detail(db, entity.id, tenant_no=tenant_no) # 允许编辑的报价单状态:草稿 / 已提交 / 审批通过 / 审批失败;保存后统一回草稿 QUOTE_EDITABLE_STATUSES = frozenset({"draft", "submitted", "accepted", "rejected"}) def assert_quote_editable_for_update(status: str) -> None: s = (status or "").strip() or "draft" if s not in QUOTE_EDITABLE_STATUSES: raise ValueError("仅草稿、已提交、审批通过或审批失败的报价单才能修改") def update_quote( db: Session, quote_id: str, *, tenant_no: str, data: dict, user_id: str = None, dept_id: str = "", user_info=None, ) -> Dict[str, Any]: """Java: updateByBo""" old = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not old: raise ValueError("报价单不存在") prev_status = (old.status or "").strip() or "draft" assert_quote_editable_for_update(prev_status) if prev_status == "submitted": delete_pending_approvals(db, quote_id) products = _get_field(data, "products", "productList") product_rows = [dict(p) for p in products] if products is not None else None quote_type = (_get_field(data, "quote_type", "quoteType") or old.quote_type or "sales").strip() old.quote_type = quote_type orig_customer_id = old.customer_id orig_supplier_id = old.supplier_id orig_quote_date = old.quote_date for field_map in ( (("quote_title", "quoteTitle"), "quote_title"), (("customer_id", "customerId"), "customer_id"), (("customer_name", "customerName"), "customer_name"), (("opportunity_id", "opportunityId"), "opportunity_id"), (("contact_id", "contactId"), "contact_id"), (("contact_name", "contactName"), "contact_name"), (("supplier_name", "supplierName"), "supplier_name"), (("payment_terms", "paymentTerms"), "payment_terms"), (("delivery_terms", "deliveryTerms"), "delivery_terms"), (("attachments",), "attachments"), (("remark",), "remark"), ): val = _get_field(data, *field_map[0]) if val is not None: setattr(old, field_map[1], val) if _get_field(data, "discount_rate", "discountRate") is not None: old.discount_rate = _decimal_value(_get_field(data, "discount_rate", "discountRate")) if _get_field(data, "validity_days", "validityDays") is not None: old.validity_days = int(_get_field(data, "validity_days", "validityDays")) if _get_field(data, "freight") is not None: old.freight = _decimal_value(_get_field(data, "freight")) if _get_field(data, "barrel_deposit", "barrelDeposit") is not None: old.barrel_deposit = _decimal_value(_get_field(data, "barrel_deposit", "barrelDeposit")) new_quote_date = _parse_optional_date(_get_field(data, "quote_date", "quoteDate")) quote_date_changed = new_quote_date is not None and new_quote_date != orig_quote_date if new_quote_date: old.quote_date = new_quote_date if _get_field(data, "supplier_id", "supplierId") is not None: old.supplier_id = (_get_field(data, "supplier_id", "supplierId") or "").strip() or None customer_changed = _get_field(data, "customer_id", "customerId") is not None and ( orig_customer_id != (_get_field(data, "customer_id", "customerId") or "").strip() ) supplier_changed = _get_field(data, "supplier_id", "supplierId") is not None and ( orig_supplier_id != old.supplier_id ) customer = get_customer(db, old.customer_id) if old.customer_id else None supplier_short_name = _get_supplier_short_name(db, old.supplier_id or "", tenant_no) if quote_date_changed and old.quote_date: old.quote_no = generate_quote_no( db, tenant_no, supplier_id=old.supplier_id, quote_date=old.quote_date ) customer_short_name = (customer.short_name or "") if customer else (old.customer_name or "") old.quote_title = generate_quote_title(old.quote_no, quote_type, supplier_short_name, customer_short_name) elif customer_changed or supplier_changed: quote_no = old.quote_no if supplier_changed and quote_no and "DD-" in quote_no: dd_index = quote_no.index("DD-") date_and_seq = quote_no[dd_index:] new_supplier_code = "SX" if old.supplier_id: supplier = get_supplier_info(db, old.supplier_id, tenant_no=tenant_no) if supplier and (supplier.supplier_code or "").strip(): new_supplier_code = supplier.supplier_code.strip() quote_no = f"{new_supplier_code}{date_and_seq}" old.quote_no = quote_no customer_short_name = (customer.short_name or "") if customer else "" current_quote_no = old.quote_no or quote_no old.quote_title = generate_quote_title(current_quote_no, quote_type, supplier_short_name, customer_short_name) if old.validity_days and old.quote_date: old.expire_date = old.quote_date + timedelta(days=int(old.validity_days)) _enrich_contact_fields(db, old) _enrich_supplier_name(db, old, tenant_no) if supplier_changed: old.template_content = None products_to_check = ( product_rows if product_rows is not None else list_quote_products(db, quote_id, tenant_no=tenant_no) ) assert_products_allowed_for_supplier( db, tenant_no=tenant_no, supplier_id=str(old.supplier_id) if old.supplier_id else None, products=products_to_check or [], ) if product_rows is not None: _resolve_quote_product_ids(db, tenant_no=tenant_no, products=product_rows, user_id=user_id) calculate_quote_amount(old, product_rows) else: calculate_quote_amount(old, list_quote_products(db, quote_id, tenant_no=tenant_no)) # 编辑保存后统一回草稿(含已提交 / 审批通过 / 审批失败) old.status = "draft" if hasattr(old, "reject_reason"): old.reject_reason = None old.update_by = user_id old.update_time = datetime.now() db.flush() if product_rows is not None: delete_products_by_quote_id(db, quote_id) if product_rows: batch_insert_quote_products( db, tenant_no=tenant_no, quote_id=quote_id, products=product_rows, user_id=user_id, dept_id=dept_id, ) db.commit() return get_quote_detail(db, quote_id, tenant_no=tenant_no) def soft_delete_quotes( db: Session, quote_ids: List[str], *, tenant_no: str, validate: bool = True, user_info=None, ) -> int: """Java: deleteWithValidByIds""" count = 0 for qid in quote_ids: entity = _get_quote_in_scope(db, qid, tenant_no=tenant_no, user_info=user_info) if not entity: continue if validate: if entity.status != "draft": raise ValueError("只能删除草稿状态的报价单") if entity.contract_id: raise ValueError("已转合同的报价单不能删除") assert_row_deletable_by_creator(entity, user_info) entity.del_flag = 1 entity.update_time = datetime.now() count += 1 db.commit() return count def submit_quote( db: Session, quote_id: str, *, tenant_no: str, approver_ids: List[str] = None, remark: str = "", user_id: str = None, dept_id: str = "", user_info=None, ) -> bool: """Java: submitQuote""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") if entity.status != "draft": raise ValueError("只有草稿状态的报价单才能提交") entity.status = "submitted" entity.update_time = datetime.now() db.flush() if approver_ids: create_approval_records( db, tenant_no=tenant_no, quote_id=quote_id, approver_ids=approver_ids, user_id=user_id, dept_id=dept_id, ) try: detail = get_quote_detail(db, quote_id, tenant_no=tenant_no) pdf_url = generate_quote_pdf(detail, detail.get("productList") or []) entity.pdf_url = pdf_url entity.pdf_generated = 1 except Exception: pass db.commit() return True def save_template( db: Session, quote_id: str, template_content: str, *, tenant_no: str, user_info=None, ) -> bool: """Java: saveTemplate""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") entity.template_content = template_content entity.update_time = datetime.now() db.commit() return True def get_template( db: Session, quote_id: str, *, tenant_no: str, user_info=None, ) -> str: """Java: getTemplate""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") return entity.template_content or "" def generate_pdf( db: Session, quote_id: str, *, tenant_no: str, user_info=None, ) -> str: """Java: generatePdf""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") detail = get_quote_detail(db, quote_id, tenant_no=tenant_no) pdf_url = generate_quote_pdf(detail, detail.get("productList") or []) entity.pdf_url = pdf_url entity.pdf_generated = 1 entity.update_time = datetime.now() db.commit() return pdf_url def generate_share( db: Session, quote_id: str, *, tenant_no: str, validity_days: int = None, password: str = "", user_info=None, ) -> str: """Java: generateShare""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") share_code = secrets.token_hex(4)[:8] share_expire_time = None if validity_days and validity_days > 0: share_expire_time = datetime.now() + timedelta(days=validity_days) share_link = f"/share/{share_code}" entity.share_code = share_code entity.share_link = share_link entity.share_password = password or None entity.share_expire_time = share_expire_time entity.update_time = datetime.now() db.commit() return share_link def view_by_share_code( db: Session, share_code: str, *, password: str = "", ip_address: str = "", user_agent: str = "", ) -> Dict[str, Any]: """Java: viewByShareCode""" entity = ( db.query(CrmQuote) .filter(CrmQuote.share_code == share_code, CrmQuote.del_flag == 0) .first() ) if not entity: raise ValueError("分享链接无效") if entity.share_expire_time and datetime.now() > entity.share_expire_time: raise ValueError("分享链接已过期") if (entity.share_password or "").strip(): if not password or entity.share_password != password: raise ValueError("分享密码错误") if entity.expire_date and date.today() > entity.expire_date and entity.status != "expired": entity.status = "expired" entity.view_count = (entity.view_count or 0) + 1 entity.last_view_time = datetime.now() if entity.status in ("submitted", "accepted"): entity.status = "viewed" entity.update_time = datetime.now() db.flush() try: record_visit( db, tenant_no=entity.tenant_no, quote_id=entity.id, share_code=share_code, ip_address=ip_address, user_agent=user_agent, ) except Exception: pass db.commit() detail = _row_to_dict(entity) detail["productList"] = list_quote_products(db, entity.id, tenant_no=entity.tenant_no) return detail def accept_quote( db: Session, quote_id: str, *, tenant_no: str, remark: str = "", user_info=None, ) -> bool: """Java: acceptQuote""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") entity.status = "accepted" if remark: entity.remark = remark entity.update_time = datetime.now() db.commit() return True def reject_quote( db: Session, quote_id: str, *, tenant_no: str, reject_reason: str = "", remark: str = "", user_info=None, ) -> bool: """Java: rejectQuote""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") entity.status = "rejected" entity.reject_reason = reject_reason or "" if remark: entity.remark = remark entity.update_time = datetime.now() db.commit() return True def to_contract( db: Session, quote_id: str, *, tenant_no: str, contract_no: str = "", contract_title: str = "", sign_date: date = None, start_date: date = None, end_date: date = None, user_id: str = None, dept_id: str = "", user_info=None, ) -> str: """Java: toContract — 返回 contractId""" sign_date = _parse_optional_date(sign_date) start_date = _parse_optional_date(start_date) end_date = _parse_optional_date(end_date) quote = get_quote(db, quote_id, tenant_no=tenant_no) if not quote: raise ValueError("报价单不存在") if quote.status != "accepted": raise ValueError("只有已接受的报价单才能转合同") if quote.contract_id: raise ValueError("该报价单已转为合同,不能重复转换") products = list_quote_products(db, quote_id, tenant_no=tenant_no) if not products: raise ValueError("报价单没有产品明细,无法转合同") from app.crud.crm.contract import create_from_quote payload: Dict[str, Any] = { "quoteId": quote_id, "customerId": quote.customer_id, "opportunityId": quote.opportunity_id, "amount": float(quote.final_amount or quote.total_amount or 0), "ownerUserId": quote.owner_user_id or user_id or "", "supplierId": quote.supplier_id, "paymentTerms": quote.payment_terms, "deliveryTerms": quote.delivery_terms, } if contract_no: payload["contractNo"] = contract_no if contract_title: payload["contractName"] = contract_title if sign_date: payload["signDate"] = sign_date.strftime(_DATE_FMT) if start_date: payload["startDate"] = start_date.strftime(_DATE_FMT) if end_date: payload["endDate"] = end_date.strftime(_DATE_FMT) qtype = (quote.quote_type or "sales").strip() payload["contractType"] = "purchase" if qtype == "purchase" else "sale" return create_from_quote( db, quote_id, tenant_no=tenant_no, data=payload, user_id=user_id, dept_id=dept_id, ) def create_new_version( db: Session, quote_id: str, *, tenant_no: str, version: str, remark: str = "", user_id: str = None, dept_id: str = "", user_info=None, ) -> str: """Java: createNewVersion — 返回新报价单 id""" old = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not old: raise ValueError("报价单不存在") validity_days = old.validity_days or 30 quote_date = date.today() now = datetime.now() new_entity = CrmQuote( id=generate_uuid(), quote_no=old.quote_no, quote_title=old.quote_title, quote_type=old.quote_type or "sales", customer_id=old.customer_id, customer_name=old.customer_name, opportunity_id=old.opportunity_id, contact_id=old.contact_id, contact_name=old.contact_name, contact_mobile=old.contact_mobile, contact_email=old.contact_email, version=version, parent_id=quote_id, is_latest=1, discount_rate=old.discount_rate, validity_days=validity_days, quote_date=quote_date, expire_date=quote_date + timedelta(days=validity_days), status="draft", owner_user_id=old.owner_user_id, owner_user_name=old.owner_user_name, supplier_id=old.supplier_id, supplier_name=old.supplier_name, payment_terms=old.payment_terms, delivery_terms=old.delivery_terms, freight=old.freight, barrel_deposit=getattr(old, "barrel_deposit", None) or Decimal("0"), tenant_no=tenant_no, remark=remark or old.remark, create_by=user_id, create_dept_id=dept_id or "", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) old.is_latest = 0 old.update_time = now old_products = list_quote_products(db, quote_id, tenant_no=tenant_no) calculate_quote_amount(new_entity, old_products) db.add(new_entity) db.flush() if old_products: batch_insert_quote_products( db, tenant_no=tenant_no, quote_id=new_entity.id, products=old_products, user_id=user_id, dept_id=dept_id, ) db.commit() return new_entity.id def query_versions( db: Session, quote_no: str, *, tenant_no: str, user_info=None, ) -> List[Dict[str, Any]]: """Java: queryVersions""" rows = ( db.query(CrmQuote) .filter( CrmQuote.quote_no == quote_no, CrmQuote.tenant_no == tenant_no, CrmQuote.del_flag == 0, ) .order_by(CrmQuote.create_time.desc()) .all() ) return [_row_to_dict(r) for r in rows] def withdraw_quote_approval( db: Session, quote_id: str, *, tenant_no: str, remark: str = "", user_id: str = None, user_info=None, ) -> bool: """Java: CrmQuoteServiceImpl.withdrawApproval(校验创建人)""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") if entity.status != "submitted": raise ValueError("只能撤回已提交的报价单") if user_id and entity.create_by and entity.create_by != user_id: raise ValueError("只有创建人才能撤回审批") entity.status = "draft" entity.update_time = datetime.now() if remark: entity.remark = remark delete_pending_approvals(db, quote_id) db.commit() return True def invalid_quote( db: Session, quote_id: str, *, tenant_no: str, remark: str = "", user_info=None, ) -> bool: """Java: invalidQuote""" entity = _get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info) if not entity: raise ValueError("报价单不存在") if entity.status == "invalid": raise ValueError("报价单已作废,不能重复作废") entity.status = "invalid" if remark: current = entity.remark or "" entity.remark = f"{current}\n【作废原因】{remark}".strip() if current else f"【作废原因】{remark}" entity.update_time = datetime.now() db.commit() return True def statistics_overview( db: Session, tenant_no: str, *, owner_user_id: str = None, begin_time: str = None, end_time: str = None, user_info=None, ) -> Dict[str, Any]: """Java: statisticsOverview""" q = db.query(CrmQuote).filter(CrmQuote.tenant_no == tenant_no, CrmQuote.del_flag == 0) if owner_user_id: q = q.filter(CrmQuote.owner_user_id == owner_user_id) begin_dt = _parse_optional_datetime(begin_time) end_dt = _parse_optional_datetime(end_time) if begin_dt: q = q.filter(CrmQuote.create_time >= begin_dt) if end_dt: q = q.filter(CrmQuote.create_time <= end_dt) if user_info: q = apply_crm_owner_scope(q, CrmQuote, user_info, db, tenant_no) all_quotes = q.all() total_count = len(all_quotes) def _count_status(status: str) -> int: return sum(1 for item in all_quotes if item.status == status) draft_count = _count_status("draft") submitted_count = _count_status("submitted") accepted_count = _count_status("accepted") rejected_count = _count_status("rejected") expired_count = _count_status("expired") total_amount = sum(_decimal_value(item.final_amount) for item in all_quotes) accepted_amount = sum( _decimal_value(item.final_amount) for item in all_quotes if item.status == "accepted" ) accept_rate = (accepted_count * 100.0 / total_count) if total_count else 0.0 avg_amount = ( (total_amount / Decimal(total_count)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if total_count else Decimal("0") ) return { "totalCount": total_count, "draftCount": draft_count, "submittedCount": submitted_count, "acceptedCount": accepted_count, "rejectedCount": rejected_count, "expiredCount": expired_count, "totalAmount": float(total_amount), "acceptedAmount": float(accepted_amount), "acceptRate": f"{accept_rate:.2f}", "avgAmount": float(avg_amount), } def statistics_status( db: Session, tenant_no: str, user_info=None, ) -> List[Dict[str, Any]]: """Java: statisticsStatus""" status_array = ("draft", "submitted", "accepted", "rejected", "expired", "contract") status_names = ("草稿", "已提交", "已接受", "已拒绝", "已过期", "已转合同") base_q = db.query(CrmQuote).filter(CrmQuote.tenant_no == tenant_no, CrmQuote.del_flag == 0) if user_info: base_q = apply_crm_owner_scope(base_q, CrmQuote, user_info, db, tenant_no) total = base_q.count() result = [] for status, status_name in zip(status_array, status_names): q = base_q.filter(CrmQuote.status == status) rows = q.all() count = len(rows) amount = sum(_decimal_value(r.final_amount) for r in rows) percent = (count * 100.0 / total) if total else 0.0 result.append( { "status": status, "statusName": status_name, "count": count, "amount": float(amount), "percent": f"{percent:.2f}", } ) return result def _period_key(dt: datetime, period_type: str) -> str: if period_type.lower() == "year": return dt.strftime("%Y年") if period_type.lower() == "quarter": quarter = (dt.month - 1) // 3 + 1 return f"{dt.year}年Q{quarter}" return dt.strftime("%Y年%m月") def statistics_trend( db: Session, tenant_no: str, *, period_type: str = "month", begin_time: str = None, end_time: str = None, user_info=None, ) -> List[Dict[str, Any]]: """Java: statisticsTrend""" q = db.query(CrmQuote).filter(CrmQuote.tenant_no == tenant_no, CrmQuote.del_flag == 0) begin_dt = _parse_optional_datetime(begin_time) end_dt = _parse_optional_datetime(end_time) if begin_dt: q = q.filter(CrmQuote.create_time >= begin_dt) if end_dt: q = q.filter(CrmQuote.create_time <= end_dt) if user_info: q = apply_crm_owner_scope(q, CrmQuote, user_info, db, tenant_no) all_quotes = q.all() if not all_quotes: return [] grouped: Dict[str, List[CrmQuote]] = defaultdict(list) for quote in all_quotes: create_time = quote.create_time or datetime.now() if quote.create_time is None and quote.quote_date: create_time = datetime.combine(quote.quote_date, datetime.min.time()) grouped[_period_key(create_time, period_type)].append(quote) result = [] for period, period_quotes in grouped.items(): accepted_count = sum( 1 for item in period_quotes if item.status in ("accepted", "contract") ) total_amount = sum(_decimal_value(item.final_amount) for item in period_quotes) accepted_amount = sum( _decimal_value(item.final_amount) for item in period_quotes if item.status in ("accepted", "contract") ) accept_rate = (accepted_count * 100.0 / len(period_quotes)) if period_quotes else 0.0 result.append( { "period": period, "totalCount": len(period_quotes), "acceptedCount": accepted_count, "totalAmount": float(total_amount), "acceptedAmount": float(accepted_amount), "acceptRate": f"{accept_rate:.2f}", } ) return result