410 lines
14 KiB
Python
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)
|