Files
shanxia2.0/backend/app/crud/crm/quote_product.py
T
2026-08-08 20:43:48 +08:00

410 lines
14 KiB
Python

"""CRM 报价单产品明细 CRUD — 对照 CrmQuoteProductServiceImpl.java"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.delete_guard import assert_row_deletable_by_creator
from app.core.utils import generate_uuid
from app.crud.crm.product import get_product
from app.models.crm_models import CrmProductBrand, CrmQuoteProduct
from app.services.crm_supplier_hazard import enrich_products_with_hazard_flags
_FMT = "%Y-%m-%d %H:%M:%S"
def _ensure_quote_parent_scope(db: Session, quote_id: str, tenant_no: str, user_info) -> None:
if user_info:
from app.crud.crm.quote import _get_quote_in_scope
_get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info)
def _decimal_value(value: Any, default: Decimal = Decimal("0")) -> Decimal:
if value is None:
return default
if isinstance(value, Decimal):
return value
return Decimal(str(value))
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 calculate_subtotal(
price: Decimal,
quantity: Decimal,
discount: Optional[Decimal] = None,
) -> Decimal:
"""Java: calculateSubtotal"""
disc = discount if discount is not None else Decimal("0")
return price * quantity * (Decimal("1") - disc / Decimal("100"))
def normalize_line_brand(*, brand_mode, brand_id, brand_name, product_has_brands: bool):
if not product_has_brands:
return None, None, None
mode = brand_mode or "any"
if mode == "required":
if not brand_id or not brand_name:
raise ValueError("指定品牌时必须选择品牌")
return "required", brand_id, brand_name
if mode == "any":
return "any", None, None
raise ValueError("无效的 brand_mode")
def product_has_brands(db: Session, product_id: str, tenant_no: str = None) -> bool:
if not product_id:
return False
q = db.query(CrmProductBrand.id).filter(
CrmProductBrand.product_id == product_id,
CrmProductBrand.del_flag == 0,
)
if tenant_no:
q = q.filter(CrmProductBrand.tenant_no == tenant_no)
return q.first() is not None
def _resolve_line_brand_fields(
db: Session,
*,
tenant_no: str,
product_id: str,
data: dict,
defaults: tuple = (None, None, None),
) -> tuple:
brand_mode = _get_field(data, "brand_mode", "brandMode")
brand_id = _get_field(data, "brand_id", "brandId")
brand_name = _get_field(data, "brand_name", "brandName")
if brand_mode is None and brand_id is None and brand_name is None:
brand_mode, brand_id, brand_name = defaults
return normalize_line_brand(
brand_mode=brand_mode,
brand_id=brand_id,
brand_name=brand_name,
product_has_brands=product_has_brands(db, product_id, tenant_no),
)
def _row_to_dict(row: CrmQuoteProduct) -> Dict[str, Any]:
"""ORM → camelCase(单点真相)"""
return {
"id": row.id,
"quoteId": row.quote_id or "",
"productId": row.product_id or "",
"productName": row.product_name or "",
"brandMode": row.brand_mode,
"brandId": row.brand_id,
"brandName": row.brand_name,
"productNo": row.product_no or "",
"categoryId": row.category_id or "",
"categoryName": row.category_name or "",
"model": row.model or "",
"unit": row.unit or "",
"quantity": float(row.quantity) if row.quantity is not None else 0.0,
"price": float(row.price) if row.price is not None else 0.0,
"discount": float(row.discount) if row.discount is not None else 0.0,
"subtotal": float(row.subtotal) if row.subtotal is not None else 0.0,
"taxAmount": float(row.tax_amount) if row.tax_amount is not None else 0.0,
"taxRate": float(row.tax_rate) if row.tax_rate is not None else 0.0,
"packageSpec": row.package_spec or "",
"deliveryMethod": row.delivery_method or "",
"description": row.description or "",
"sort": row.sort if row.sort is not None else 0,
"remark": row.remark or "",
"createTime": row.create_time.strftime(_FMT) if row.create_time else "",
"updateTime": row.update_time.strftime(_FMT) if row.update_time else "",
}
def product_row_to_dict(row: CrmQuoteProduct) -> Dict[str, Any]:
return _row_to_dict(row)
def list_quote_products(
db: Session,
quote_id: str,
*,
tenant_no: str = None,
user_info=None,
) -> List[Dict[str, Any]]:
"""Java: queryListByQuoteId"""
if tenant_no and user_info:
_ensure_quote_parent_scope(db, quote_id, tenant_no, user_info)
q = db.query(CrmQuoteProduct).filter(
CrmQuoteProduct.quote_id == quote_id,
CrmQuoteProduct.del_flag == 0,
)
if tenant_no:
q = q.filter(CrmQuoteProduct.tenant_no == tenant_no)
rows = q.order_by(CrmQuoteProduct.sort.asc(), CrmQuoteProduct.create_time.asc()).all()
result = [_row_to_dict(r) for r in rows]
enrich_products_with_hazard_flags(db, result)
return result
def list_products_by_quote_ids(
db: Session,
quote_ids: List[str],
*,
tenant_no: str = None,
) -> Dict[str, List[Dict[str, Any]]]:
"""按报价单 ID 批量加载产品明细(供列表摘要展示)"""
ids = [str(qid).strip() for qid in quote_ids if qid]
if not ids:
return {}
q = db.query(CrmQuoteProduct).filter(
CrmQuoteProduct.quote_id.in_(ids),
CrmQuoteProduct.del_flag == 0,
)
if tenant_no:
q = q.filter(CrmQuoteProduct.tenant_no == tenant_no)
rows = q.order_by(
CrmQuoteProduct.quote_id.asc(),
CrmQuoteProduct.sort.asc(),
CrmQuoteProduct.create_time.asc(),
).all()
result: Dict[str, List[Dict[str, Any]]] = {qid: [] for qid in ids}
for row in rows:
qid = str(row.quote_id or "")
if not qid:
continue
result.setdefault(qid, []).append(_row_to_dict(row))
return result
def get_quote_product(db: Session, product_line_id: str) -> Optional[CrmQuoteProduct]:
return (
db.query(CrmQuoteProduct)
.filter(CrmQuoteProduct.id == product_line_id, CrmQuoteProduct.del_flag == 0)
.first()
)
def insert_quote_product(
db: Session,
*,
tenant_no: str,
quote_id: str,
data: dict,
user_id: str = None,
dept_id: str = "",
user_info=None,
) -> CrmQuoteProduct:
"""Java: insertByBo"""
_ensure_quote_parent_scope(db, quote_id, tenant_no, user_info)
price = _decimal_value(_get_field(data, "price"), Decimal("0"))
quantity = _decimal_value(_get_field(data, "quantity"), Decimal("0"))
discount = _decimal_value(_get_field(data, "discount"), Decimal("0"))
subtotal = calculate_subtotal(price, quantity, discount)
product_id = (_get_field(data, "product_id", "productId") or "").strip()
entity = CrmQuoteProduct(
id=generate_uuid(),
quote_id=quote_id,
product_id=product_id,
product_name=_get_field(data, "product_name", "productName") or "",
product_no=_get_field(data, "product_no", "productNo") or "",
category_id=_get_field(data, "category_id", "categoryId") or "",
category_name=_get_field(data, "category_name", "categoryName") or "",
model=_get_field(data, "model") or "",
unit=_get_field(data, "unit") or "件",
quantity=quantity,
price=price,
discount=discount,
subtotal=subtotal,
tax_amount=_decimal_value(_get_field(data, "tax_amount", "taxAmount"), Decimal("0")),
tax_rate=_decimal_value(_get_field(data, "tax_rate", "taxRate"), Decimal("13.00")),
package_spec=_get_field(data, "package_spec", "packageSpec") or "",
delivery_method=_get_field(data, "delivery_method", "deliveryMethod") or "",
description=_get_field(data, "description") or "",
sort=_get_field(data, "sort"),
tenant_no=tenant_no,
remark=_get_field(data, "remark") or "",
create_by=user_id,
create_dept_id=dept_id or "",
del_flag=0,
)
if product_id:
product = get_product(db, product_id)
if product:
if not entity.product_name:
entity.product_name = product.product_name or ""
if not entity.product_no:
entity.product_no = product.product_no or ""
if not entity.category_id:
entity.category_id = product.category_id or ""
if not entity.category_name:
entity.category_name = product.category_name or ""
if not entity.model:
entity.model = product.model or ""
if not (entity.unit or "").strip():
entity.unit = product.unit or "件"
if not entity.product_name:
raise ValueError("产品名称不能为空")
brand_mode, brand_id, brand_name = _resolve_line_brand_fields(
db,
tenant_no=tenant_no,
product_id=product_id,
data=data,
)
entity.brand_mode = brand_mode
entity.brand_id = brand_id
entity.brand_name = brand_name
db.add(entity)
db.flush()
return entity
def update_quote_product(
db: Session,
product_line_id: str,
*,
data: dict,
user_id: str = None,
user_info=None,
) -> CrmQuoteProduct:
"""Java: updateByBo"""
entity = get_quote_product(db, product_line_id)
if not entity:
raise ValueError("报价产品明细不存在")
if user_info:
_ensure_quote_parent_scope(db, entity.quote_id, entity.tenant_no, user_info)
if "product_id" in data or "productId" in data:
entity.product_id = (_get_field(data, "product_id", "productId") or entity.product_id or "").strip()
for src_keys, attr in (
(("product_name", "productName"), "product_name"),
(("product_no", "productNo"), "product_no"),
(("category_id", "categoryId"), "category_id"),
(("category_name", "categoryName"), "category_name"),
(("model",), "model"),
(("unit",), "unit"),
(("description",), "description"),
(("package_spec", "packageSpec"), "package_spec"),
(("delivery_method", "deliveryMethod"), "delivery_method"),
(("remark",), "remark"),
(("sort",), "sort"),
):
val = _get_field(data, *src_keys)
if val is not None:
setattr(entity, attr, val)
if "quantity" in data:
entity.quantity = _decimal_value(_get_field(data, "quantity"), entity.quantity)
if "price" in data:
entity.price = _decimal_value(_get_field(data, "price"), entity.price)
if "discount" in data:
entity.discount = _decimal_value(_get_field(data, "discount"), entity.discount)
if "tax_amount" in data or "taxAmount" in data:
entity.tax_amount = _decimal_value(_get_field(data, "tax_amount", "taxAmount"), entity.tax_amount)
if "tax_rate" in data or "taxRate" in data:
entity.tax_rate = _decimal_value(_get_field(data, "tax_rate", "taxRate"), entity.tax_rate)
entity.subtotal = calculate_subtotal(entity.price, entity.quantity, entity.discount)
brand_keys = ("brand_mode", "brandMode", "brand_id", "brandId", "brand_name", "brandName")
if any(k in data for k in brand_keys) or "product_id" in data or "productId" in data:
brand_mode, brand_id, brand_name = _resolve_line_brand_fields(
db,
tenant_no=entity.tenant_no,
product_id=entity.product_id or "",
data=data,
defaults=(entity.brand_mode, entity.brand_id, entity.brand_name),
)
entity.brand_mode = brand_mode
entity.brand_id = brand_id
entity.brand_name = brand_name
entity.update_by = user_id
entity.update_time = datetime.now()
db.flush()
return entity
def soft_delete_quote_products(
db: Session,
ids: List[str],
*,
validate_min_one: bool = True,
user_info=None,
) -> int:
"""Java: deleteWithValidByIds"""
count = 0
for pid in ids:
entity = get_quote_product(db, pid)
if not entity:
continue
if user_info:
_ensure_quote_parent_scope(db, entity.quote_id, entity.tenant_no, user_info)
if validate_min_one:
sibling_count = (
db.query(CrmQuoteProduct)
.filter(
CrmQuoteProduct.quote_id == entity.quote_id,
CrmQuoteProduct.del_flag == 0,
)
.count()
)
if sibling_count <= 1:
raise ValueError("报价单至少保留1个产品")
assert_row_deletable_by_creator(entity, user_info)
entity.del_flag = 1
entity.update_time = datetime.now()
count += 1
db.flush()
return count
def batch_insert_quote_products(
db: Session,
*,
tenant_no: str,
quote_id: str,
products: List[dict],
user_id: str = None,
dept_id: str = "",
) -> None:
"""Java: batchInsert"""
if not products:
return
sort = 1
for item in products:
row = dict(item)
row["sort"] = row.get("sort") or sort
sort += 1
row.pop("id", None)
insert_quote_product(
db,
tenant_no=tenant_no,
quote_id=quote_id,
data=row,
user_id=user_id,
dept_id=dept_id,
)
def delete_products_by_quote_id(db: Session, quote_id: str) -> int:
"""Java: deleteByQuoteId — 软删除该报价单下全部明细"""
rows = (
db.query(CrmQuoteProduct)
.filter(CrmQuoteProduct.quote_id == quote_id, CrmQuoteProduct.del_flag == 0)
.all()
)
now = datetime.now()
for row in rows:
row.del_flag = 1
row.update_time = now
db.flush()
return len(rows)