Files
shanxia2.0/backend/app/crud/crm/contract_product.py
T
2026-08-19 22:20:42 +08:00

420 lines
15 KiB
Python

"""CRM 合同产品明细 CRUD — 对照 CrmContractProductServiceImpl.java"""
from __future__ import annotations
import json
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.crm.product import get_product
from app.crud.crm.quote_product import _resolve_line_brand_fields
from app.models.crm_models import CrmContractProduct, CrmContractProductPriceTier
from app.services.crm_supplier_hazard import enrich_products_with_hazard_flags
_FMT = "%Y-%m-%d %H:%M:%S"
_DATE_FMT = "%Y-%m-%d"
def _row_to_dict(row: CrmContractProduct) -> Dict[str, Any]:
return {
"id": row.id,
"contractId": row.contract_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,
"quantityMode": row.quantity_mode or "fixed",
"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,
"taxRate": float(row.tax_rate) if row.tax_rate is not None else 0.0,
"taxAmount": float(row.tax_amount) if row.tax_amount is not None else 0.0,
"subtotal": float(row.subtotal) if row.subtotal is not None else 0.0,
"priceMode": row.price_mode or "fixed",
"deliveryDate": row.delivery_date.strftime(_DATE_FMT) if row.delivery_date else "",
"deliveryStatus": row.delivery_status or "",
"deliveredQuantity": float(row.delivered_quantity) if row.delivered_quantity is not None else 0.0,
"description": row.description or "",
"packageSpec": row.package_spec or "",
"deliveryMethod": row.delivery_method 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 "",
"priceTiers": [],
}
def _tier_row_to_dict(row: CrmContractProductPriceTier) -> Dict[str, Any]:
letter_oss_ids: List[str] = []
raw = (row.letter_oss_ids or "").strip()
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
letter_oss_ids = [str(x) for x in parsed]
except json.JSONDecodeError:
letter_oss_ids = [raw]
return {
"id": row.id,
"contractId": row.contract_id or "",
"contractProductId": row.contract_product_id or "",
"startDate": row.start_date.strftime(_DATE_FMT) if row.start_date else "",
"endDate": row.end_date.strftime(_DATE_FMT) if row.end_date else "",
"price": float(row.price) if row.price is not None else 0.0,
"letterOssIds": letter_oss_ids,
"letterOssId": row.letter_oss_id or "",
"sort": row.sort if row.sort is not None else 0,
"remark": row.remark or "",
}
def list_contract_products(
db: Session,
contract_id: str,
*,
tenant_no: str = None,
include_price_tiers: bool = False,
user_info=None,
) -> List[Dict[str, Any]]:
if tenant_no and user_info:
from app.crud.crm.contract import _get_contract_row_in_scope
_get_contract_row_in_scope(db, contract_id, tenant_no=tenant_no, user_info=user_info)
q = db.query(CrmContractProduct).filter(
CrmContractProduct.contract_id == contract_id,
CrmContractProduct.del_flag == 0,
)
if tenant_no:
q = q.filter(CrmContractProduct.tenant_no == tenant_no)
rows = q.order_by(CrmContractProduct.sort.asc(), CrmContractProduct.create_time.asc()).all()
result = [_row_to_dict(r) for r in rows]
if include_price_tiers and result:
product_ids = [item["id"] for item in result if item.get("id")]
tier_q = db.query(CrmContractProductPriceTier).filter(
CrmContractProductPriceTier.contract_id == contract_id,
CrmContractProductPriceTier.contract_product_id.in_(product_ids),
CrmContractProductPriceTier.del_flag == 0,
)
if tenant_no:
tier_q = tier_q.filter(CrmContractProductPriceTier.tenant_no == tenant_no)
tiers = tier_q.order_by(
CrmContractProductPriceTier.sort.asc(),
CrmContractProductPriceTier.start_date.asc(),
).all()
tier_map: Dict[str, List[Dict[str, Any]]] = {}
for tier in tiers:
tier_map.setdefault(tier.contract_product_id, []).append(_tier_row_to_dict(tier))
for item in result:
item["priceTiers"] = tier_map.get(item["id"], [])
enrich_products_with_hazard_flags(db, result)
return result
def list_products_by_contract_ids(
db: Session,
contract_ids: List[str],
*,
tenant_no: str = None,
) -> Dict[str, List[Dict[str, Any]]]:
"""按合同 ID 批量加载产品明细(不含价阶、危化标记,供列表摘要展示)"""
ids = [str(cid).strip() for cid in contract_ids if cid]
if not ids:
return {}
q = db.query(CrmContractProduct).filter(
CrmContractProduct.contract_id.in_(ids),
CrmContractProduct.del_flag == 0,
)
if tenant_no:
q = q.filter(CrmContractProduct.tenant_no == tenant_no)
rows = q.order_by(
CrmContractProduct.contract_id.asc(),
CrmContractProduct.sort.asc(),
CrmContractProduct.create_time.asc(),
).all()
result: Dict[str, List[Dict[str, Any]]] = {cid: [] for cid in ids}
for row in rows:
cid = str(row.contract_id or "")
if not cid:
continue
result.setdefault(cid, []).append(_row_to_dict(row))
return result
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 _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 _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
return None
def calculate_subtotal(
price: Decimal,
quantity: Decimal,
discount: Optional[Decimal] = None,
) -> Decimal:
disc = discount if discount is not None else Decimal("0")
return price * quantity * (Decimal("1") - disc / Decimal("100"))
def _letter_oss_ids_to_json(letter_oss_ids: Any) -> str:
if not letter_oss_ids:
return ""
if isinstance(letter_oss_ids, str):
return letter_oss_ids.strip()
if isinstance(letter_oss_ids, list):
return json.dumps([str(x) for x in letter_oss_ids if x], ensure_ascii=False)
return ""
def insert_contract_product(
db: Session,
*,
tenant_no: str,
contract_id: str,
data: dict,
user_id: str = None,
dept_id: str = "",
) -> CrmContractProduct:
"""Java: insertByBo"""
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 = _decimal_value(_get_field(data, "subtotal"))
if subtotal is None or subtotal == Decimal("0"):
subtotal = calculate_subtotal(price, quantity, discount)
product_id = (_get_field(data, "product_id", "productId") or "").strip()
if not product_id:
raise ValueError("产品ID不能为空")
now = datetime.now()
entity = CrmContractProduct(
id=generate_uuid(),
contract_id=contract_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,
quantity_mode=_get_field(data, "quantity_mode", "quantityMode") or "fixed",
price=price,
discount=discount,
tax_rate=_decimal_value(_get_field(data, "tax_rate", "taxRate"), Decimal("6.00")),
tax_amount=_decimal_value(_get_field(data, "tax_amount", "taxAmount"), Decimal("0")),
subtotal=subtotal,
price_mode=_get_field(data, "price_mode", "priceMode") or "fixed",
delivery_date=_parse_optional_date(_get_field(data, "delivery_date", "deliveryDate")),
delivery_status=_get_field(data, "delivery_status", "deliveryStatus") or "pending",
delivered_quantity=_decimal_value(_get_field(data, "delivered_quantity", "deliveredQuantity"), Decimal("0")),
description=_get_field(data, "description") or "",
package_spec=_get_field(data, "package_spec", "packageSpec") or "",
delivery_method=_get_field(data, "delivery_method", "deliveryMethod") or "",
sort=_get_field(data, "sort") if _get_field(data, "sort") is not None else 0,
tenant_no=tenant_no,
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,
)
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 batch_insert_contract_products(
db: Session,
*,
tenant_no: str,
contract_id: str,
products: List[dict],
contract_type: str = "",
user_id: str = None,
dept_id: str = "",
) -> List[CrmContractProduct]:
"""批量插入产品;框架合同保存调价区间"""
if not products:
return []
entities: List[CrmContractProduct] = []
sort = 1
for item in products:
row = dict(item)
row["sort"] = row.get("sort") or sort
sort += 1
row.pop("id", None)
entity = insert_contract_product(
db,
tenant_no=tenant_no,
contract_id=contract_id,
data=row,
user_id=user_id,
dept_id=dept_id,
)
if (contract_type or "").strip() in (
"framework",
"framework_sale",
"framework_purchase",
):
tiers = _get_field(row, "priceTiers", "price_tiers") or []
if tiers:
save_price_tiers_for_product(
db,
tenant_no=tenant_no,
contract_id=contract_id,
contract_product_id=entity.id,
tiers=tiers,
user_id=user_id,
dept_id=dept_id,
)
entities.append(entity)
return entities
def delete_price_tiers_by_contract_id(db: Session, contract_id: str) -> int:
rows = (
db.query(CrmContractProductPriceTier)
.filter(
CrmContractProductPriceTier.contract_id == contract_id,
CrmContractProductPriceTier.del_flag == 0,
)
.all()
)
now = datetime.now()
for row in rows:
row.del_flag = 1
row.update_time = now
db.flush()
return len(rows)
def delete_products_by_contract_id(db: Session, contract_id: str) -> int:
"""Java: deleteByContractId — 软删除产品明细及调价区间"""
delete_price_tiers_by_contract_id(db, contract_id)
rows = (
db.query(CrmContractProduct)
.filter(CrmContractProduct.contract_id == contract_id, CrmContractProduct.del_flag == 0)
.all()
)
now = datetime.now()
for row in rows:
row.del_flag = 1
row.update_time = now
db.flush()
return len(rows)
def save_price_tiers_for_product(
db: Session,
*,
tenant_no: str,
contract_id: str,
contract_product_id: str,
tiers: List[dict],
user_id: str = None,
dept_id: str = "",
) -> None:
"""Java: savePriceTiersForProduct"""
if not tiers:
return
now = datetime.now()
sort = 1
for tier in tiers:
start_date = _parse_optional_date(_get_field(tier, "startDate", "start_date"))
end_date = _parse_optional_date(_get_field(tier, "endDate", "end_date"))
if not start_date or not end_date:
raise ValueError("调价区间开始/结束日期不能为空")
letter_ids = _get_field(tier, "letterOssIds", "letter_oss_ids") or []
entity = CrmContractProductPriceTier(
id=generate_uuid(),
contract_id=contract_id,
contract_product_id=contract_product_id,
start_date=start_date,
end_date=end_date,
price=_decimal_value(_get_field(tier, "price"), Decimal("0")),
letter_oss_ids=_letter_oss_ids_to_json(letter_ids),
letter_oss_id=_get_field(tier, "letterOssId", "letter_oss_id") or "",
sort=_get_field(tier, "sort") if _get_field(tier, "sort") is not None else sort,
tenant_no=tenant_no,
remark=_get_field(tier, "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,
)
sort += 1
db.add(entity)
db.flush()