Files
shanxia2.0/backend/app/crud/crm/quote.py
T
2026-08-08 23:14:40 +08:00

1471 lines
50 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 报价单 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