420 lines
15 KiB
Python
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()
|