1225 lines
40 KiB
Python
1225 lines
40 KiB
Python
"""CRM 产品模块 CRUD(产品分类 + 产品 + 附件)"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from sqlalchemy import and_, func, or_
|
||
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.data_scope_helpers import (
|
||
apply_crm_create_by_scope,
|
||
ensure_crm_create_by_row_in_scope,
|
||
)
|
||
from app.models.crm_models import (
|
||
CrmProduct,
|
||
CrmProductAlias,
|
||
CrmProductBrand,
|
||
CrmProductCategory,
|
||
SysOss,
|
||
)
|
||
from app.models.pl_models import PlInventory, PlPlan, PlPlanProduct
|
||
from app.services.crm.product_list_filters import build_product_column_filters
|
||
from app.utils.pinyin_abbr import resolve_name_pinyin_abbr
|
||
from fastapi import HTTPException
|
||
|
||
|
||
# ── 工具函数 ──────────────────────────────────────────────────
|
||
|
||
|
||
def total_pages(total: int, page_size: int) -> int:
|
||
"""计算总页数"""
|
||
if page_size <= 0:
|
||
return 0
|
||
return math.ceil(total / page_size)
|
||
|
||
|
||
# ── 产品分类 CRUD ────────────────────────────────────────────
|
||
|
||
|
||
def _category_row_to_dict(row: CrmProductCategory) -> Dict[str, Any]:
|
||
"""分类 ORM 对象转 dict(camelCase 键名)"""
|
||
return {
|
||
"id": row.id,
|
||
"parentId": row.parent_id,
|
||
"categoryName": row.category_name,
|
||
"categoryCode": row.category_code or "",
|
||
"level": row.level or 1,
|
||
"status": row.status,
|
||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||
"remark": row.remark or "",
|
||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
||
}
|
||
|
||
|
||
def list_categories(
|
||
db: Session,
|
||
tenant_no: str,
|
||
*,
|
||
category_name: Optional[str] = None,
|
||
status: Optional[str] = None,
|
||
user_info=None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""获取分类(扁平列表,支持名称模糊与状态筛选)"""
|
||
q = db.query(CrmProductCategory).filter(
|
||
CrmProductCategory.tenant_no == tenant_no,
|
||
CrmProductCategory.del_flag == 0,
|
||
)
|
||
# 产品分类为租户主数据(树选择/管理共用),不做 create_by 过滤
|
||
_ = user_info
|
||
name_kw = (category_name or "").strip()
|
||
if name_kw:
|
||
q = q.filter(CrmProductCategory.category_name.like(f"%{name_kw}%"))
|
||
if status is not None and str(status).strip() != "":
|
||
q = q.filter(CrmProductCategory.status == str(status).strip())
|
||
rows = q.order_by(
|
||
CrmProductCategory.order_num.asc(),
|
||
CrmProductCategory.create_time.asc(),
|
||
).all()
|
||
return [_category_row_to_dict(r) for r in rows]
|
||
|
||
|
||
def _build_category_tree(
|
||
items: List[Dict[str, Any]], parent_id: str = "0"
|
||
) -> List[Dict[str, Any]]:
|
||
"""按 parentId 将扁平分类列表组装为嵌套树"""
|
||
children = [item for item in items if (item.get("parentId") or "0") == parent_id]
|
||
for child in children:
|
||
child["children"] = _build_category_tree(items, child["id"])
|
||
children.sort(key=lambda x: (x.get("orderNum", 0) or 0))
|
||
return children
|
||
|
||
|
||
def _collect_category_with_ancestors(
|
||
items: List[Dict[str, Any]], seed_ids: set[str]
|
||
) -> List[Dict[str, Any]]:
|
||
"""保留匹配节点及其全部上级,供检索后仍能正确组树。"""
|
||
by_id = {item["id"]: item for item in items if item.get("id")}
|
||
keep: set[str] = set()
|
||
for sid in seed_ids:
|
||
cur = (sid or "").strip()
|
||
while cur and cur != "0":
|
||
if cur not in by_id:
|
||
break
|
||
keep.add(cur)
|
||
cur = (by_id[cur].get("parentId") or "0").strip()
|
||
return [by_id[i] for i in keep if i in by_id]
|
||
|
||
|
||
def list_category_tree(
|
||
db: Session,
|
||
tenant_no: str,
|
||
*,
|
||
category_name: Optional[str] = None,
|
||
status: Optional[str] = None,
|
||
user_info=None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""获取产品分类树(嵌套 children,供树形表格/树选择器使用)"""
|
||
name_kw = (category_name or "").strip()
|
||
status_val = str(status).strip() if status is not None else ""
|
||
has_filter = bool(name_kw) or bool(status_val)
|
||
|
||
if not has_filter:
|
||
flat = list_categories(db, tenant_no, user_info=user_info)
|
||
return _build_category_tree(flat)
|
||
|
||
matched = list_categories(
|
||
db,
|
||
tenant_no,
|
||
category_name=category_name,
|
||
status=status,
|
||
user_info=user_info,
|
||
)
|
||
if not matched:
|
||
return []
|
||
|
||
all_flat = list_categories(db, tenant_no)
|
||
seed_ids = {m["id"] for m in matched if m.get("id")}
|
||
expanded = _collect_category_with_ancestors(all_flat, seed_ids)
|
||
return _build_category_tree(expanded)
|
||
|
||
|
||
def get_category(db: Session, category_id: str) -> Optional[CrmProductCategory]:
|
||
"""根据ID获取分类"""
|
||
return (
|
||
db.query(CrmProductCategory)
|
||
.filter(
|
||
CrmProductCategory.id == category_id,
|
||
CrmProductCategory.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
|
||
|
||
def create_category(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: str = None,
|
||
**kwargs,
|
||
) -> CrmProductCategory:
|
||
"""创建分类"""
|
||
parent_id = kwargs.get("parent_id", "0") or "0"
|
||
level = kwargs.get("level") or 1
|
||
|
||
# 自动计算 level
|
||
if parent_id != "0":
|
||
parent = get_category(db, parent_id)
|
||
if parent:
|
||
level = (parent.level or 0) + 1
|
||
|
||
obj = CrmProductCategory(
|
||
id=generate_uuid(),
|
||
parent_id=parent_id,
|
||
category_name=kwargs.get("category_name"),
|
||
category_code=kwargs.get("category_code"),
|
||
level=level,
|
||
status=kwargs.get("status", "1"),
|
||
tenant_no=tenant_no,
|
||
order_num=kwargs.get("order_num"),
|
||
remark=kwargs.get("remark"),
|
||
create_by=user_id,
|
||
create_dept_id="",
|
||
del_flag=0,
|
||
)
|
||
db.add(obj)
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def update_category(
|
||
db: Session,
|
||
category_id: str,
|
||
*,
|
||
user_id: str = None,
|
||
user_info=None,
|
||
**kwargs,
|
||
) -> CrmProductCategory:
|
||
"""更新分类"""
|
||
obj = get_category(db, category_id)
|
||
if not obj:
|
||
raise ValueError("分类不存在")
|
||
ensure_crm_create_by_row_in_scope(
|
||
db, obj, user_info, obj.tenant_no, message="无权访问该分类"
|
||
)
|
||
|
||
# 逐字段更新
|
||
for field in ("category_name", "category_code", "status", "order_num", "remark"):
|
||
if field in kwargs and kwargs[field] is not None:
|
||
setattr(obj, field, kwargs[field])
|
||
|
||
# parent_id 变了则重新计算 level
|
||
if "parent_id" in kwargs and kwargs["parent_id"] is not None:
|
||
new_parent_id = kwargs["parent_id"]
|
||
obj.parent_id = new_parent_id
|
||
if new_parent_id == "0":
|
||
obj.level = 1
|
||
else:
|
||
parent = get_category(db, new_parent_id)
|
||
if parent:
|
||
obj.level = (parent.level or 0) + 1
|
||
|
||
obj.update_by = user_id
|
||
obj.update_time = datetime.now()
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def soft_delete_category(db: Session, category_id: str, *, user_info=None) -> None:
|
||
"""软删除分类"""
|
||
obj = get_category(db, category_id)
|
||
if not obj:
|
||
raise ValueError("分类不存在")
|
||
ensure_crm_create_by_row_in_scope(
|
||
db, obj, user_info, obj.tenant_no, message="无权访问该分类"
|
||
)
|
||
|
||
# 检查是否有子分类
|
||
has_children = (
|
||
db.query(CrmProductCategory)
|
||
.filter(
|
||
CrmProductCategory.parent_id == category_id,
|
||
CrmProductCategory.del_flag == 0,
|
||
)
|
||
.first()
|
||
is not None
|
||
)
|
||
if has_children:
|
||
raise ValueError("该分类下存在子分类/产品,无法删除")
|
||
|
||
# 检查是否有产品引用
|
||
has_products = (
|
||
db.query(CrmProduct)
|
||
.filter(
|
||
CrmProduct.category_id == category_id,
|
||
CrmProduct.del_flag == 0,
|
||
)
|
||
.first()
|
||
is not None
|
||
)
|
||
if has_products:
|
||
raise ValueError("该分类下存在子分类/产品,无法删除")
|
||
|
||
assert_row_deletable_by_creator(obj, user_info)
|
||
obj.del_flag = 1
|
||
db.commit()
|
||
|
||
|
||
def category_code_exists(
|
||
db: Session,
|
||
category_code: str,
|
||
tenant_no: str,
|
||
exclude_id: str = None,
|
||
) -> bool:
|
||
"""检查分类编码是否已存在"""
|
||
q = db.query(CrmProductCategory).filter(
|
||
CrmProductCategory.category_code == category_code,
|
||
CrmProductCategory.tenant_no == tenant_no,
|
||
CrmProductCategory.del_flag == 0,
|
||
)
|
||
if exclude_id:
|
||
q = q.filter(CrmProductCategory.id != exclude_id)
|
||
return q.first() is not None
|
||
|
||
|
||
# ── 产品 CRUD ────────────────────────────────────────────────
|
||
|
||
|
||
def _brand_row_to_dict(row: CrmProductBrand) -> Dict[str, Any]:
|
||
return {
|
||
"id": row.id,
|
||
"brandName": row.brand_name,
|
||
"status": row.status or "on",
|
||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||
}
|
||
|
||
|
||
def _alias_row_to_dict(row: CrmProductAlias) -> Dict[str, Any]:
|
||
return {
|
||
"id": row.id,
|
||
"aliasName": row.alias_name,
|
||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||
}
|
||
|
||
|
||
def _load_brands_aliases_maps(
|
||
db: Session, product_ids: List[str], tenant_no: str
|
||
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, List[Dict[str, Any]]]]:
|
||
brands_map: Dict[str, List[Dict[str, Any]]] = {pid: [] for pid in product_ids}
|
||
aliases_map: Dict[str, List[Dict[str, Any]]] = {pid: [] for pid in product_ids}
|
||
if not product_ids:
|
||
return brands_map, aliases_map
|
||
brand_rows = (
|
||
db.query(CrmProductBrand)
|
||
.filter(
|
||
CrmProductBrand.product_id.in_(product_ids),
|
||
CrmProductBrand.tenant_no == tenant_no,
|
||
CrmProductBrand.del_flag == 0,
|
||
)
|
||
.order_by(CrmProductBrand.order_num.asc(), CrmProductBrand.create_time.asc())
|
||
.all()
|
||
)
|
||
for row in brand_rows:
|
||
brands_map.setdefault(row.product_id, []).append(_brand_row_to_dict(row))
|
||
alias_rows = (
|
||
db.query(CrmProductAlias)
|
||
.filter(
|
||
CrmProductAlias.product_id.in_(product_ids),
|
||
CrmProductAlias.tenant_no == tenant_no,
|
||
CrmProductAlias.del_flag == 0,
|
||
)
|
||
.order_by(CrmProductAlias.order_num.asc(), CrmProductAlias.create_time.asc())
|
||
.all()
|
||
)
|
||
for row in alias_rows:
|
||
aliases_map.setdefault(row.product_id, []).append(_alias_row_to_dict(row))
|
||
return brands_map, aliases_map
|
||
|
||
|
||
def _assert_brand_deletable(db: Session, tenant_no: str, brand_id: str, brand_name: str) -> None:
|
||
if not brand_id:
|
||
return
|
||
inv = (
|
||
db.query(PlInventory)
|
||
.filter(
|
||
PlInventory.tenant_no == tenant_no,
|
||
PlInventory.brand_id == brand_id,
|
||
PlInventory.del_flag == 0,
|
||
PlInventory.stock_qty > 0,
|
||
)
|
||
.first()
|
||
)
|
||
if inv:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"品牌「{brand_name or brand_id}」仍有库存,无法删除",
|
||
)
|
||
plan_ref = (
|
||
db.query(PlPlanProduct)
|
||
.join(PlPlan, PlPlan.id == PlPlanProduct.plan_id)
|
||
.filter(
|
||
PlPlanProduct.tenant_no == tenant_no,
|
||
PlPlanProduct.brand_id == brand_id,
|
||
PlPlanProduct.del_flag == 0,
|
||
PlPlan.del_flag == 0,
|
||
PlPlan.archive_status != "archived",
|
||
)
|
||
.first()
|
||
)
|
||
if plan_ref:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"品牌「{brand_name or brand_id}」仍被未归档计划引用,无法删除",
|
||
)
|
||
|
||
|
||
def _prepare_product_metadata(db, *, product_id, tenant_no, values, kind):
|
||
"""Validate all submitted identities and removal guards before changing any row."""
|
||
if values is None:
|
||
return None
|
||
model = CrmProductBrand if kind == "brands" else CrmProductAlias
|
||
field, alias = ("brand_name", "brandName") if kind == "brands" else ("alias_name", "aliasName")
|
||
with db.no_autoflush:
|
||
existing = db.query(model).filter(
|
||
model.product_id == product_id, model.tenant_no == tenant_no, model.del_flag == 0,
|
||
).all()
|
||
by_id = {row.id: row for row in existing}
|
||
submitted, explicit = [], set()
|
||
for item in values:
|
||
data = dict(item) if isinstance(item, dict) else item.model_dump(exclude_unset=True)
|
||
identity = str(data.get("id") or "").strip()
|
||
if identity:
|
||
if identity in explicit or identity not in by_id:
|
||
raise ValueError("品牌或别名 ID 重复、无效或不属于当前产品")
|
||
explicit.add(identity)
|
||
name = str(data.get(field) or data.get(alias) or "").strip()
|
||
if not name:
|
||
raise ValueError("品牌或别名名称不能为空")
|
||
submitted.append((identity, name, data))
|
||
jobs, used, anonymous_names = [], set(), set()
|
||
for identity, name, data in submitted:
|
||
row = by_id.get(identity)
|
||
if not identity:
|
||
if name in anonymous_names:
|
||
raise ValueError("无 ID 的品牌或别名名称重复,请回传明确 ID")
|
||
anonymous_names.add(name)
|
||
candidates = [old for old in existing if old.id not in explicit
|
||
and str(getattr(old, field) or "").strip() == name]
|
||
if len(candidates) > 1:
|
||
raise ValueError("品牌或别名存在多个同名记录,请回传明确 ID")
|
||
row = candidates[0] if candidates else None
|
||
if row is not None:
|
||
used.add(row.id)
|
||
jobs.append((row, name, data))
|
||
removed = [row for row in existing if row.id not in used]
|
||
if kind == "brands":
|
||
for row in removed:
|
||
_assert_brand_deletable(db, tenant_no, row.id, row.brand_name)
|
||
return jobs, removed
|
||
|
||
|
||
def _apply_product_metadata(db, *, product_id, tenant_no, user_id, kind, prepared):
|
||
if prepared is None:
|
||
return
|
||
model = CrmProductBrand if kind == "brands" else CrmProductAlias
|
||
field = "brand_name" if kind == "brands" else "alias_name"
|
||
jobs, removed = prepared
|
||
now = datetime.now()
|
||
for index, (row, name, data) in enumerate(jobs):
|
||
order_num = data.get("order_num") if data.get("order_num") is not None else data.get("orderNum")
|
||
if row is None:
|
||
row = model(id=generate_uuid(), product_id=product_id, tenant_no=tenant_no,
|
||
create_by=user_id, create_dept_id="", del_flag=0,
|
||
order_num=index if order_num is None else order_num)
|
||
db.add(row)
|
||
if kind == "brands":
|
||
row.status = str(data.get("status") or "on").strip() or "on"
|
||
else:
|
||
row.update_by = user_id
|
||
row.update_time = now
|
||
if order_num is not None:
|
||
row.order_num = order_num
|
||
if kind == "brands" and "status" in data:
|
||
row.status = str(data.get("status") or "on").strip() or "on"
|
||
setattr(row, field, name)
|
||
for row in removed:
|
||
row.del_flag = 1
|
||
row.update_by = user_id
|
||
row.update_time = now
|
||
|
||
|
||
def _replace_product_brands(db: Session, *, product_id: str, tenant_no: str,
|
||
user_id: Optional[str], brands: Optional[List[Any]], prepared=None) -> None:
|
||
plan = prepared if prepared is not None else _prepare_product_metadata(
|
||
db, product_id=product_id, tenant_no=tenant_no, values=brands, kind="brands")
|
||
_apply_product_metadata(db, product_id=product_id, tenant_no=tenant_no,
|
||
user_id=user_id, kind="brands", prepared=plan)
|
||
|
||
|
||
def _replace_product_aliases(db: Session, *, product_id: str, tenant_no: str,
|
||
user_id: Optional[str], aliases: Optional[List[Any]], prepared=None) -> None:
|
||
plan = prepared if prepared is not None else _prepare_product_metadata(
|
||
db, product_id=product_id, tenant_no=tenant_no, values=aliases, kind="aliases")
|
||
_apply_product_metadata(db, product_id=product_id, tenant_no=tenant_no,
|
||
user_id=user_id, kind="aliases", prepared=plan)
|
||
|
||
|
||
def _product_row_to_dict(
|
||
row: CrmProduct,
|
||
*,
|
||
brands: Optional[List[Dict[str, Any]]] = None,
|
||
aliases: Optional[List[Dict[str, Any]]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""产品 ORM 对象转 dict(camelCase 键名)"""
|
||
unit = (row.unit or "").strip()
|
||
return {
|
||
"id": row.id,
|
||
"productNo": row.product_no,
|
||
"productName": row.product_name,
|
||
"namePinyinAbbr": getattr(row, "name_pinyin_abbr", None) or "",
|
||
"categoryId": row.category_id or "",
|
||
"categoryName": row.category_name or "",
|
||
"model": row.model or "",
|
||
"unit": unit or None,
|
||
"barcode": row.barcode or "",
|
||
"price": float(row.price) if row.price is not None else None,
|
||
"costPrice": float(row.cost_price) if row.cost_price is not None else None,
|
||
"cost": float(row.cost_price) if row.cost_price is not None else None,
|
||
"stock": row.stock if row.stock is not None else 0,
|
||
"lowStock": row.low_stock if row.low_stock is not None else 10,
|
||
"taxRate": float(row.tax_rate) if row.tax_rate is not None else 13.0,
|
||
"isToxic": int(row.is_toxic or 0),
|
||
"isExplosive": int(row.is_explosive or 0),
|
||
"status": row.status,
|
||
"description": row.description or "",
|
||
"images": row.images or "",
|
||
"saleCount": row.sale_count if row.sale_count is not None else 0,
|
||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||
"remark": row.remark or "",
|
||
"brands": brands if brands is not None else [],
|
||
"aliases": aliases if aliases is not None else [],
|
||
"createBy": row.create_by or "",
|
||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
||
}
|
||
|
||
|
||
def product_to_dict(db: Session, row: CrmProduct) -> Dict[str, Any]:
|
||
"""单产品详情(含品牌/别名)"""
|
||
brands_map, aliases_map = _load_brands_aliases_maps(db, [row.id], row.tenant_no)
|
||
return _product_row_to_dict(
|
||
row,
|
||
brands=brands_map.get(row.id, []),
|
||
aliases=aliases_map.get(row.id, []),
|
||
)
|
||
|
||
|
||
def _apply_product_keyword_filter(
|
||
q,
|
||
keyword: str = None,
|
||
*,
|
||
match_product_no: bool = True,
|
||
match_model: bool = False,
|
||
):
|
||
"""列表及主表/子表候选值使用相同的产品关键词匹配规则。"""
|
||
kw = (keyword or "").strip()
|
||
if kw:
|
||
like = f"%{kw}%"
|
||
pinyin_like = f"%{kw.lower()}%"
|
||
conds = [
|
||
CrmProduct.product_name.like(like),
|
||
func.lower(func.coalesce(CrmProduct.name_pinyin_abbr, "")).like(pinyin_like),
|
||
]
|
||
if match_product_no:
|
||
conds.append(CrmProduct.product_no.like(like))
|
||
if match_model:
|
||
conds.append(CrmProduct.model.like(like))
|
||
q = q.filter(or_(*conds))
|
||
return q
|
||
|
||
|
||
def list_products(
|
||
db: Session,
|
||
tenant_no: str,
|
||
*,
|
||
page: int = 1,
|
||
page_size: int = 10,
|
||
keyword: str = None,
|
||
supplier_id: str = None,
|
||
ids: Optional[List[str]] = None,
|
||
user_info=None,
|
||
match_product_no: bool = True,
|
||
match_model: bool = False,
|
||
max_page_size: Optional[int] = None,
|
||
**filter_params,
|
||
) -> Tuple[List[Dict[str, Any]], int]:
|
||
"""分页查询产品列表
|
||
|
||
keyword 默认匹配:名称 / 编号 / 拼音首字母。
|
||
选择器场景可关闭编号匹配、开启规格型号匹配(避免短数字误命中产品编号)。
|
||
列头 status / category_id / 文本数字日期等走 build_product_column_filters。
|
||
"""
|
||
q = db.query(CrmProduct).filter(
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
)
|
||
if user_info:
|
||
q = apply_crm_create_by_scope(q, CrmProduct, user_info, db, tenant_no)
|
||
|
||
if ids:
|
||
q = q.filter(CrmProduct.id.in_(ids))
|
||
|
||
q = _apply_product_keyword_filter(
|
||
q, keyword, match_product_no=match_product_no, match_model=match_model
|
||
)
|
||
|
||
for expr in build_product_column_filters(**filter_params):
|
||
q = q.filter(expr)
|
||
|
||
if supplier_id:
|
||
from app.services.crm_supplier_hazard import get_supplier_hazard_flags
|
||
|
||
allow_toxic, allow_explosive = get_supplier_hazard_flags(db, supplier_id, tenant_no=tenant_no)
|
||
conds = [
|
||
and_(
|
||
or_(CrmProduct.is_toxic.is_(None), CrmProduct.is_toxic != 1),
|
||
or_(CrmProduct.is_explosive.is_(None), CrmProduct.is_explosive != 1),
|
||
)
|
||
]
|
||
if allow_toxic == 1:
|
||
conds.append(and_(CrmProduct.is_toxic == 1, or_(CrmProduct.is_explosive.is_(None), CrmProduct.is_explosive != 1)))
|
||
if allow_explosive == 1:
|
||
conds.append(and_(CrmProduct.is_explosive == 1, or_(CrmProduct.is_toxic.is_(None), CrmProduct.is_toxic != 1)))
|
||
if allow_toxic == 1 and allow_explosive == 1:
|
||
conds.append(and_(CrmProduct.is_toxic == 1, CrmProduct.is_explosive == 1))
|
||
q = q.filter(or_(*conds))
|
||
|
||
total = q.order_by(None).with_entities(func.count(CrmProduct.id)).scalar() or 0
|
||
page = max(1, page)
|
||
cap = max_page_size if max_page_size is not None else 500
|
||
page_size = min(max(1, page_size), cap)
|
||
|
||
rows = (
|
||
q.order_by(CrmProduct.product_no.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
row_ids = [r.id for r in rows]
|
||
brands_map, aliases_map = _load_brands_aliases_maps(db, row_ids, tenant_no)
|
||
return [
|
||
_product_row_to_dict(
|
||
r,
|
||
brands=brands_map.get(r.id, []),
|
||
aliases=aliases_map.get(r.id, []),
|
||
)
|
||
for r in rows
|
||
], int(total)
|
||
|
||
|
||
def get_product(db: Session, product_id: str) -> Optional[CrmProduct]:
|
||
"""根据ID获取产品"""
|
||
return (
|
||
db.query(CrmProduct)
|
||
.filter(
|
||
CrmProduct.id == product_id,
|
||
CrmProduct.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
|
||
|
||
def get_product_by_no(db: Session, product_no: str, tenant_no: str) -> Optional[CrmProduct]:
|
||
"""根据编号获取产品"""
|
||
return (
|
||
db.query(CrmProduct)
|
||
.filter(
|
||
CrmProduct.product_no == product_no,
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
|
||
|
||
PRODUCT_NO_PREFIX = "SXWL"
|
||
PRODUCT_NO_SEQ_LEN = 6
|
||
PRODUCT_NO_GENERATE_MAX_RETRIES = 10
|
||
PRODUCT_NO_SEQ_MAX = 10 ** PRODUCT_NO_SEQ_LEN - 1
|
||
|
||
|
||
def _max_sxwl_product_seq(db: Session, tenant_no: str) -> int:
|
||
"""取当前租户下 SXWL 前缀且后缀为纯数字的产品编号最大流水值"""
|
||
prefix_len = len(PRODUCT_NO_PREFIX)
|
||
rows = (
|
||
db.query(CrmProduct.product_no)
|
||
.filter(
|
||
CrmProduct.product_no.like(f"{PRODUCT_NO_PREFIX}%"),
|
||
CrmProduct.tenant_no == tenant_no,
|
||
)
|
||
.all()
|
||
)
|
||
max_seq = 0
|
||
for (product_no,) in rows:
|
||
if not product_no or len(product_no) <= prefix_len:
|
||
continue
|
||
suffix = product_no[prefix_len:]
|
||
if suffix.isdigit():
|
||
max_seq = max(max_seq, int(suffix))
|
||
return max_seq
|
||
|
||
|
||
def generate_product_no(db: Session, tenant_no: str) -> str:
|
||
"""生成产品编号(格式:SXWL + 6位递增流水号,如 SXWL000001)"""
|
||
seq = _max_sxwl_product_seq(db, tenant_no) + 1
|
||
for _ in range(PRODUCT_NO_GENERATE_MAX_RETRIES):
|
||
if seq > PRODUCT_NO_SEQ_MAX:
|
||
raise ValueError("产品编号序号已达上限")
|
||
candidate = f"{PRODUCT_NO_PREFIX}{seq:0{PRODUCT_NO_SEQ_LEN}d}"
|
||
if not product_no_exists(db, candidate, tenant_no):
|
||
return candidate
|
||
seq += 1
|
||
raise ValueError("自动生成产品编号失败,请稍后重试")
|
||
|
||
|
||
def generate_barcode(db: Session, tenant_no: str) -> str:
|
||
"""生成唯一条码(纯数字:yyyyMMdd + 6位序号,如 20260606000001)"""
|
||
today_str = datetime.now().strftime("%Y%m%d")
|
||
prefix = today_str
|
||
|
||
max_barcode = (
|
||
db.query(CrmProduct.barcode)
|
||
.filter(
|
||
CrmProduct.barcode.like(f"{prefix}%"),
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
)
|
||
.order_by(CrmProduct.barcode.desc())
|
||
.first()
|
||
)
|
||
|
||
if max_barcode and max_barcode[0]:
|
||
seq_str = max_barcode[0][len(prefix):]
|
||
try:
|
||
seq = int(seq_str) + 1
|
||
except ValueError:
|
||
seq = 1
|
||
else:
|
||
seq = 1
|
||
|
||
return f"{prefix}{seq:06d}"
|
||
|
||
|
||
def create_product(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: str = None,
|
||
**kwargs,
|
||
) -> CrmProduct:
|
||
"""创建产品"""
|
||
product_no = kwargs.get("product_no")
|
||
if not product_no:
|
||
product_no = generate_product_no(db, tenant_no)
|
||
|
||
barcode = _normalize_text(kwargs.get("barcode"))
|
||
if not barcode:
|
||
barcode = generate_barcode(db, tenant_no)
|
||
elif barcode_exists(db, barcode, tenant_no):
|
||
raise ValueError(f"条码 {barcode} 已存在")
|
||
|
||
# 如果传了 category_id,查分类表冗余写入 category_name
|
||
category_id = kwargs.get("category_id")
|
||
category_name = kwargs.get("category_name")
|
||
if category_id and not category_name:
|
||
cat = get_category(db, category_id)
|
||
if cat:
|
||
category_name = cat.category_name
|
||
|
||
# 拼音首字母:新增始终按提交值解析(空则由产品名称生成);兼容 snake / camel 键
|
||
submitted = kwargs.get("name_pinyin_abbr")
|
||
if submitted is None and "namePinyinAbbr" in kwargs:
|
||
submitted = kwargs.get("namePinyinAbbr")
|
||
name_pinyin_abbr = resolve_name_pinyin_abbr(
|
||
name=kwargs.get("product_name"),
|
||
submitted=submitted,
|
||
submitted_provided=True,
|
||
)
|
||
|
||
obj = CrmProduct(
|
||
id=generate_uuid(),
|
||
product_no=product_no,
|
||
product_name=kwargs.get("product_name"),
|
||
name_pinyin_abbr=name_pinyin_abbr,
|
||
category_id=category_id,
|
||
category_name=category_name or "",
|
||
model=kwargs.get("model"),
|
||
unit=_normalize_text(kwargs.get("unit")) or None,
|
||
barcode=barcode,
|
||
price=kwargs.get("price") if "price" in kwargs else None,
|
||
cost_price=kwargs.get("cost_price") if "cost_price" in kwargs else None,
|
||
stock=kwargs.get("stock", 0),
|
||
low_stock=kwargs.get("low_stock", 10),
|
||
tax_rate=kwargs.get("tax_rate", Decimal("13.00")),
|
||
is_toxic=int(kwargs.get("is_toxic") or 0),
|
||
is_explosive=int(kwargs.get("is_explosive") or 0),
|
||
status=kwargs.get("status", "on"),
|
||
description=kwargs.get("description"),
|
||
images=kwargs.get("images"),
|
||
sale_count=kwargs.get("sale_count", 0),
|
||
tenant_no=tenant_no,
|
||
order_num=kwargs.get("order_num"),
|
||
remark=kwargs.get("remark"),
|
||
create_by=user_id,
|
||
create_dept_id="",
|
||
del_flag=0,
|
||
)
|
||
prepared_metadata = {
|
||
kind: _prepare_product_metadata(db, product_id=obj.id, tenant_no=tenant_no,
|
||
values=kwargs.get(kind), kind=kind)
|
||
for kind in ("brands", "aliases")
|
||
}
|
||
db.add(obj)
|
||
db.flush()
|
||
_replace_product_brands(
|
||
db,
|
||
product_id=obj.id,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
brands=kwargs.get("brands"),
|
||
prepared=prepared_metadata["brands"],
|
||
)
|
||
_replace_product_aliases(
|
||
db,
|
||
product_id=obj.id,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
aliases=kwargs.get("aliases"),
|
||
prepared=prepared_metadata["aliases"],
|
||
)
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def update_product(
|
||
db: Session,
|
||
product_id: str,
|
||
*,
|
||
user_id: str = None,
|
||
user_info=None,
|
||
**kwargs,
|
||
) -> CrmProduct:
|
||
"""更新产品"""
|
||
obj = get_product(db, product_id)
|
||
if not obj:
|
||
raise ValueError("产品不存在")
|
||
ensure_crm_create_by_row_in_scope(
|
||
db, obj, user_info, obj.tenant_no, message="无权访问该产品"
|
||
)
|
||
|
||
prepared_metadata = {
|
||
kind: _prepare_product_metadata(db, product_id=obj.id, tenant_no=obj.tenant_no,
|
||
values=kwargs.get(kind), kind=kind)
|
||
for kind in ("brands", "aliases")
|
||
}
|
||
|
||
# 逐字段更新(name_pinyin_abbr 单独处理,避免双重赋值)
|
||
for field in (
|
||
"product_no",
|
||
"product_name",
|
||
"model",
|
||
"unit",
|
||
"price",
|
||
"cost_price",
|
||
"stock",
|
||
"low_stock",
|
||
"tax_rate",
|
||
"is_toxic",
|
||
"is_explosive",
|
||
"status",
|
||
"description",
|
||
"images",
|
||
"sale_count",
|
||
"order_num",
|
||
"remark",
|
||
):
|
||
if field in kwargs and kwargs[field] is not None:
|
||
setattr(obj, field, kwargs[field])
|
||
|
||
# 拼音首字母:提交了则用提交值(空则重生);未提交则保留已有,已有空则按名称生成
|
||
name_keys = ("name_pinyin_abbr", "namePinyinAbbr")
|
||
submitted_provided = any(k in kwargs for k in name_keys)
|
||
submitted = kwargs.get("name_pinyin_abbr")
|
||
if submitted is None:
|
||
submitted = kwargs.get("namePinyinAbbr")
|
||
if "product_name" in kwargs and kwargs.get("product_name") is not None:
|
||
new_name = kwargs.get("product_name")
|
||
else:
|
||
new_name = obj.product_name
|
||
obj.name_pinyin_abbr = resolve_name_pinyin_abbr(
|
||
name=new_name,
|
||
submitted=submitted,
|
||
existing=obj.name_pinyin_abbr,
|
||
submitted_provided=submitted_provided,
|
||
)
|
||
|
||
if "barcode" in kwargs:
|
||
barcode = _normalize_text(kwargs.get("barcode"))
|
||
if barcode and barcode_exists(db, barcode, tenant_no=obj.tenant_no, exclude_id=product_id):
|
||
raise ValueError(f"条码 {barcode} 已存在")
|
||
obj.barcode = barcode or None
|
||
|
||
# category_id 变了则同步更新 category_name
|
||
if "category_id" in kwargs and kwargs["category_id"] is not None:
|
||
cat_id = kwargs["category_id"]
|
||
obj.category_id = cat_id
|
||
if cat_id:
|
||
cat = get_category(db, cat_id)
|
||
obj.category_name = cat.category_name if cat else ""
|
||
else:
|
||
obj.category_name = ""
|
||
|
||
if "brands" in kwargs:
|
||
_replace_product_brands(
|
||
db,
|
||
product_id=obj.id,
|
||
tenant_no=obj.tenant_no,
|
||
user_id=user_id,
|
||
brands=kwargs.get("brands"),
|
||
prepared=prepared_metadata["brands"],
|
||
)
|
||
if "aliases" in kwargs:
|
||
_replace_product_aliases(
|
||
db,
|
||
product_id=obj.id,
|
||
tenant_no=obj.tenant_no,
|
||
user_id=user_id,
|
||
aliases=kwargs.get("aliases"),
|
||
prepared=prepared_metadata["aliases"],
|
||
)
|
||
|
||
obj.update_by = user_id
|
||
obj.update_time = datetime.now()
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def soft_delete_product(db: Session, product_id: str, *, user_info=None) -> None:
|
||
"""软删除产品"""
|
||
obj = get_product(db, product_id)
|
||
if not obj:
|
||
raise ValueError("产品不存在")
|
||
ensure_crm_create_by_row_in_scope(
|
||
db, obj, user_info, obj.tenant_no, message="无权访问该产品"
|
||
)
|
||
assert_row_deletable_by_creator(obj, user_info)
|
||
obj.del_flag = 1
|
||
db.commit()
|
||
|
||
|
||
def product_no_exists(
|
||
db: Session,
|
||
product_no: str,
|
||
tenant_no: str,
|
||
exclude_id: str = None,
|
||
) -> bool:
|
||
"""检查产品编号是否已存在"""
|
||
q = db.query(CrmProduct).filter(
|
||
CrmProduct.product_no == product_no,
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
)
|
||
if exclude_id:
|
||
q = q.filter(CrmProduct.id != exclude_id)
|
||
return q.first() is not None
|
||
|
||
|
||
def barcode_exists(
|
||
db: Session,
|
||
barcode: str,
|
||
tenant_no: str,
|
||
exclude_id: str = None,
|
||
) -> bool:
|
||
"""检查条码是否已存在"""
|
||
q = db.query(CrmProduct).filter(
|
||
CrmProduct.barcode == barcode,
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
)
|
||
if exclude_id:
|
||
q = q.filter(CrmProduct.id != exclude_id)
|
||
return q.first() is not None
|
||
|
||
|
||
def list_product_options(
|
||
db: Session,
|
||
tenant_no: str,
|
||
*,
|
||
keyword: str = None,
|
||
category_id: str = None,
|
||
user_info=None,
|
||
limit: int = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""获取产品下拉选项(不分页,返回全量)"""
|
||
q = db.query(CrmProduct).filter(
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
CrmProduct.status == "on",
|
||
)
|
||
# 下拉选项供合同/报价等选用,不做 create_by 过滤(产品管理分页列表仍保留数据权限)
|
||
# keyword:产品名称 + 拼音首字母(不匹配编号,避免短数字如「32」误命中 SXWL00732x)
|
||
_ = user_info
|
||
kw = (keyword or "").strip()
|
||
if kw:
|
||
q = q.filter(
|
||
or_(
|
||
CrmProduct.product_name.like(f"%{kw}%"),
|
||
func.lower(func.coalesce(CrmProduct.name_pinyin_abbr, "")).like(
|
||
f"%{kw.lower()}%"
|
||
),
|
||
)
|
||
)
|
||
if category_id:
|
||
q = q.filter(CrmProduct.category_id == category_id)
|
||
q = q.order_by(CrmProduct.create_time.desc())
|
||
if limit:
|
||
q = q.limit(min(max(1, int(limit)), 500))
|
||
rows = q.all()
|
||
return [
|
||
{
|
||
"id": r.id,
|
||
"label": r.product_name,
|
||
"value": r.id,
|
||
"productName": r.product_name,
|
||
"productNo": r.product_no,
|
||
"model": r.model,
|
||
"unit": r.unit,
|
||
"status": r.status,
|
||
"price": float(r.price) if r.price is not None else None,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
_PRODUCT_COLUMN_FACET_FIELDS = {
|
||
"productNo": CrmProduct.product_no,
|
||
"productName": CrmProduct.product_name,
|
||
"categoryName": CrmProduct.category_name,
|
||
"model": CrmProduct.model,
|
||
}
|
||
|
||
# 子表 facet:brand_name / alias_name 去重(限定上架产品范围)
|
||
_PRODUCT_SUB_FACET_FIELDS = {
|
||
"brands": (CrmProductBrand, CrmProductBrand.brand_name),
|
||
"aliases": (CrmProductAlias, CrmProductAlias.alias_name),
|
||
}
|
||
|
||
|
||
def list_product_column_facet_values(
|
||
db: Session,
|
||
tenant_no: str,
|
||
*,
|
||
field: str,
|
||
keyword: str = None,
|
||
limit: int = 500,
|
||
user_info=None,
|
||
filter_kwargs: Optional[dict] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""产品列表列头可选值(去重):productNo / productName / categoryName / model 主表列;brands / aliases 子表列。
|
||
|
||
与产品管理列表一致:status=on + tenant + 数据权限;可带列表上下文列筛。
|
||
"""
|
||
from sqlalchemy import func as sa_func
|
||
|
||
from app.services.column_facet import list_column_facet_values
|
||
from app.services.crm.product_list_filters import build_product_column_filters
|
||
|
||
key = str(field or "").strip()
|
||
ctx_filters = (
|
||
build_product_column_filters(**filter_kwargs) if filter_kwargs else []
|
||
)
|
||
|
||
def _scope(q):
|
||
q = _apply_product_keyword_filter(q, keyword)
|
||
if user_info:
|
||
return apply_crm_create_by_scope(q, CrmProduct, user_info, db, tenant_no)
|
||
return q
|
||
|
||
sub = _PRODUCT_SUB_FACET_FIELDS.get(key)
|
||
if sub is not None:
|
||
sub_model, sub_col = sub
|
||
lim = min(max(1, int(limit or 500)), 500)
|
||
q = (
|
||
db.query(sub_col.label("value"), sa_func.count(CrmProduct.id).label("cnt"))
|
||
.select_from(CrmProduct)
|
||
.join(sub_model, sub_model.product_id == CrmProduct.id)
|
||
.filter(
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
CrmProduct.status == "on",
|
||
sub_model.tenant_no == tenant_no,
|
||
sub_model.del_flag == 0,
|
||
sub_col.isnot(None),
|
||
sub_col != "",
|
||
)
|
||
)
|
||
for expr in ctx_filters:
|
||
q = q.filter(expr)
|
||
q = _scope(q)
|
||
rows = q.group_by(sub_col).order_by(sub_col.asc()).limit(lim).all()
|
||
out: List[Dict[str, Any]] = []
|
||
for row in rows:
|
||
value = str(row.value).strip() if row.value is not None else ""
|
||
if not value:
|
||
continue
|
||
out.append({"value": value, "label": value, "count": int(row.cnt or 0)})
|
||
return out
|
||
|
||
col = _PRODUCT_COLUMN_FACET_FIELDS.get(key)
|
||
if col is None:
|
||
raise ValueError("field 仅支持 productNo / productName / categoryName / brands / aliases / model")
|
||
|
||
extra = [CrmProduct.status == "on"]
|
||
extra.extend(ctx_filters)
|
||
return list_column_facet_values(
|
||
db,
|
||
CrmProduct,
|
||
col,
|
||
tenant_no=tenant_no,
|
||
limit=limit,
|
||
apply_scope=_scope,
|
||
extra_filters=extra,
|
||
)
|
||
|
||
|
||
# ── 附件 CRUD ────────────────────────────────────────────────
|
||
|
||
|
||
def _oss_row_to_dict(row: SysOss) -> Dict[str, Any]:
|
||
"""附件 ORM 对象转 dict"""
|
||
return {
|
||
"id": row.id,
|
||
"bizType": row.biz_type or "",
|
||
"bizId": row.biz_id or "",
|
||
"fileName": row.file_name or "",
|
||
"originalName": row.original_name or "",
|
||
"fileSuffix": row.file_suffix or "",
|
||
"url": row.url or "",
|
||
"fileSize": row.file_size or 0,
|
||
"fileType": row.file_type or "",
|
||
"attachType": row.attach_type or "attachment",
|
||
"sortOrder": row.sort_order or 0,
|
||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||
}
|
||
|
||
|
||
def list_oss_by_biz(db: Session, biz_type: str, biz_id: str, tenant_no: str) -> List[Dict[str, Any]]:
|
||
"""按业务类型+ID 查询附件列表(不分页)"""
|
||
rows = (
|
||
db.query(SysOss)
|
||
.filter(
|
||
SysOss.biz_type == biz_type,
|
||
SysOss.biz_id == biz_id,
|
||
SysOss.tenant_no == tenant_no,
|
||
SysOss.del_flag == 0,
|
||
)
|
||
.order_by(SysOss.sort_order.asc())
|
||
.all()
|
||
)
|
||
return [_oss_row_to_dict(r) for r in rows]
|
||
|
||
|
||
def create_oss(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: str = None,
|
||
**kwargs,
|
||
) -> SysOss:
|
||
"""创建附件记录"""
|
||
obj = SysOss(
|
||
id=generate_uuid(),
|
||
tenant_no=tenant_no,
|
||
biz_type=kwargs.get("biz_type", ""),
|
||
biz_id=kwargs.get("biz_id", ""),
|
||
file_name=kwargs.get("file_name", ""),
|
||
original_name=kwargs.get("original_name", ""),
|
||
file_suffix=kwargs.get("file_suffix", ""),
|
||
url=kwargs.get("url", ""),
|
||
file_size=kwargs.get("file_size", 0),
|
||
file_type=kwargs.get("file_type", ""),
|
||
attach_type=kwargs.get("attach_type", "attachment"),
|
||
sort_order=kwargs.get("sort_order", 0),
|
||
service=kwargs.get("service", "minio"),
|
||
create_by=user_id,
|
||
create_dept_id="",
|
||
del_flag=0,
|
||
)
|
||
db.add(obj)
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def soft_delete_oss(db: Session, oss_id: str) -> None:
|
||
"""软删除附件"""
|
||
obj = db.query(SysOss).filter(SysOss.id == oss_id, SysOss.del_flag == 0).first()
|
||
if obj:
|
||
obj.del_flag = 1
|
||
db.commit()
|
||
|
||
|
||
# ── 报价/合同行产品 ID 解析(禁止自动建档) ─────────────
|
||
|
||
|
||
def _normalize_text(value: Any) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
trimmed = str(value).strip()
|
||
return trimmed if trimmed else None
|
||
|
||
|
||
def _get_quote_item_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_product_id_for_quote_item(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
product_data: dict,
|
||
user_id: str = None,
|
||
line_no: int = None,
|
||
) -> str:
|
||
"""解析报价/合同明细产品 ID:必须带本租户有效 productId,禁止自动新建产品。"""
|
||
del user_id # 保留参数兼容旧调用方
|
||
if not product_data:
|
||
raise ValueError("产品明细不能为空")
|
||
|
||
tip = (
|
||
f"第{line_no}行请从产品库选择产品"
|
||
if line_no is not None
|
||
else "请从产品库选择产品"
|
||
)
|
||
existing_id = (_get_quote_item_field(product_data, "product_id", "productId") or "").strip()
|
||
if not existing_id:
|
||
raise ValueError(tip)
|
||
|
||
found = get_product(db, existing_id)
|
||
if found and found.tenant_no == tenant_no:
|
||
return existing_id
|
||
|
||
raise ValueError(tip)
|