Add ids/columns support and unify toolbar export UX. Co-authored-by: Cursor <cursoragent@cursor.com>
1201 lines
38 KiB
Python
1201 lines
38 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.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 _replace_product_brands(
|
||
db: Session,
|
||
*,
|
||
product_id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str],
|
||
brands: Optional[List[Any]],
|
||
) -> None:
|
||
if brands is None:
|
||
return
|
||
existing = (
|
||
db.query(CrmProductBrand)
|
||
.filter(
|
||
CrmProductBrand.product_id == product_id,
|
||
CrmProductBrand.tenant_no == tenant_no,
|
||
CrmProductBrand.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
keep_ids = set()
|
||
for item in brands:
|
||
data = item if isinstance(item, dict) else item.model_dump(by_alias=False)
|
||
bid = (data.get("id") or "").strip() or None
|
||
name = (data.get("brand_name") or data.get("brandName") or "").strip()
|
||
if not name:
|
||
continue
|
||
if bid:
|
||
keep_ids.add(bid)
|
||
for row in existing:
|
||
if row.id not in keep_ids:
|
||
_assert_brand_deletable(db, tenant_no, row.id, row.brand_name)
|
||
row.del_flag = 1
|
||
row.update_by = user_id
|
||
row.update_time = datetime.now()
|
||
existing_by_id = {r.id: r for r in existing}
|
||
for idx, item in enumerate(brands):
|
||
data = item if isinstance(item, dict) else item.model_dump(by_alias=False)
|
||
name = (data.get("brand_name") or data.get("brandName") or "").strip()
|
||
if not name:
|
||
continue
|
||
bid = (data.get("id") or "").strip() or None
|
||
status = (data.get("status") or "on").strip() or "on"
|
||
order_num = data.get("order_num") if data.get("order_num") is not None else data.get("orderNum")
|
||
if order_num is None:
|
||
order_num = idx
|
||
if bid and bid in existing_by_id:
|
||
row = existing_by_id[bid]
|
||
row.del_flag = 0
|
||
row.brand_name = name
|
||
row.status = status
|
||
row.order_num = order_num
|
||
row.update_by = user_id
|
||
row.update_time = datetime.now()
|
||
else:
|
||
db.add(
|
||
CrmProductBrand(
|
||
id=generate_uuid(),
|
||
product_id=product_id,
|
||
brand_name=name,
|
||
status=status,
|
||
tenant_no=tenant_no,
|
||
order_num=order_num,
|
||
create_by=user_id,
|
||
create_dept_id="",
|
||
del_flag=0,
|
||
)
|
||
)
|
||
|
||
|
||
def _replace_product_aliases(
|
||
db: Session,
|
||
*,
|
||
product_id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str],
|
||
aliases: Optional[List[Any]],
|
||
) -> None:
|
||
if aliases is None:
|
||
return
|
||
existing = (
|
||
db.query(CrmProductAlias)
|
||
.filter(
|
||
CrmProductAlias.product_id == product_id,
|
||
CrmProductAlias.tenant_no == tenant_no,
|
||
CrmProductAlias.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
existing_by_id = {r.id: r for r in existing}
|
||
keep_ids = set()
|
||
for item in aliases:
|
||
data = item if isinstance(item, dict) else item.model_dump(by_alias=False)
|
||
aid = (data.get("id") or "").strip() or None
|
||
name = (data.get("alias_name") or data.get("aliasName") or "").strip()
|
||
if name and aid:
|
||
keep_ids.add(aid)
|
||
for row in existing:
|
||
if row.id not in keep_ids:
|
||
row.del_flag = 1
|
||
row.update_by = user_id
|
||
row.update_time = datetime.now()
|
||
for idx, item in enumerate(aliases):
|
||
data = item if isinstance(item, dict) else item.model_dump(by_alias=False)
|
||
name = (data.get("alias_name") or data.get("aliasName") or "").strip()
|
||
if not name:
|
||
continue
|
||
aid = (data.get("id") or "").strip() or None
|
||
order_num = data.get("order_num") if data.get("order_num") is not None else data.get("orderNum")
|
||
if order_num is None:
|
||
order_num = idx
|
||
if aid and aid in existing_by_id:
|
||
row = existing_by_id[aid]
|
||
row.del_flag = 0
|
||
row.alias_name = name
|
||
row.order_num = order_num
|
||
row.update_by = user_id
|
||
row.update_time = datetime.now()
|
||
else:
|
||
db.add(
|
||
CrmProductAlias(
|
||
id=generate_uuid(),
|
||
product_id=product_id,
|
||
alias_name=name,
|
||
tenant_no=tenant_no,
|
||
order_num=order_num,
|
||
create_by=user_id,
|
||
create_dept_id="",
|
||
del_flag=0,
|
||
)
|
||
)
|
||
|
||
|
||
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 [],
|
||
"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 list_products(
|
||
db: Session,
|
||
tenant_no: str,
|
||
*,
|
||
page: int = 1,
|
||
page_size: int = 10,
|
||
keyword: str = None,
|
||
category_id: str = None,
|
||
status: 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,
|
||
) -> Tuple[List[Dict[str, Any]], int]:
|
||
"""分页查询产品列表
|
||
|
||
keyword 默认匹配:名称 / 编号 / 拼音首字母。
|
||
选择器场景可关闭编号匹配、开启规格型号匹配(避免短数字误命中产品编号)。
|
||
"""
|
||
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))
|
||
|
||
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))
|
||
|
||
# category_id 筛选
|
||
if category_id:
|
||
q = q.filter(CrmProduct.category_id == category_id)
|
||
|
||
# status 筛选
|
||
if status:
|
||
q = q.filter(CrmProduct.status == status)
|
||
|
||
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()
|
||
)
|
||
ids = [r.id for r in rows]
|
||
brands_map, aliases_map = _load_brands_aliases_maps(db, 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,
|
||
)
|
||
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"),
|
||
)
|
||
_replace_product_aliases(
|
||
db,
|
||
product_id=obj.id,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
aliases=kwargs.get("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="无权访问该产品"
|
||
)
|
||
|
||
# 逐字段更新(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"),
|
||
)
|
||
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"),
|
||
)
|
||
|
||
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
|
||
]
|
||
|
||
|
||
# ── 附件 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 解析(对照 CrmProductServiceImpl) ─────────────
|
||
|
||
|
||
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 generate_auto_product_no(db: Session, tenant_no: str) -> str:
|
||
"""报价自动建档时生成产品编号(与产品管理新增规则一致)"""
|
||
return generate_product_no(db, tenant_no)
|
||
|
||
|
||
def resolve_product_id_for_quote_item(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
product_data: dict,
|
||
user_id: str = None,
|
||
) -> str:
|
||
"""Java: resolveProductIdForQuoteItem(CrmProduct 无 tax_rate,跳过税率匹配)"""
|
||
if not product_data:
|
||
raise ValueError("产品明细不能为空")
|
||
|
||
existing_id = (_get_quote_item_field(product_data, "product_id", "productId") or "").strip()
|
||
if existing_id:
|
||
found = get_product(db, existing_id)
|
||
if found and found.tenant_no == tenant_no:
|
||
return existing_id
|
||
|
||
product_name = _normalize_text(_get_quote_item_field(product_data, "product_name", "productName"))
|
||
if not product_name:
|
||
raise ValueError("产品名称不能为空")
|
||
model = _normalize_text(_get_quote_item_field(product_data, "model"))
|
||
unit = _normalize_text(_get_quote_item_field(product_data, "unit"))
|
||
category_id = (_get_quote_item_field(product_data, "category_id", "categoryId") or "").strip() or None
|
||
|
||
name_key = product_name.lower()
|
||
model_key = (model or "").lower()
|
||
unit_key = unit.lower()
|
||
|
||
candidates = (
|
||
db.query(CrmProduct)
|
||
.filter(
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
func.lower(func.trim(func.coalesce(CrmProduct.product_name, ""))) == name_key,
|
||
func.lower(func.trim(func.coalesce(CrmProduct.model, ""))) == model_key,
|
||
func.lower(func.trim(func.coalesce(CrmProduct.unit, ""))) == unit_key,
|
||
)
|
||
.order_by(CrmProduct.id.desc())
|
||
.all()
|
||
)
|
||
|
||
if candidates:
|
||
if category_id:
|
||
for candidate in candidates:
|
||
if candidate.category_id == category_id:
|
||
return candidate.id
|
||
return candidates[0].id
|
||
|
||
category_name = _normalize_text(_get_quote_item_field(product_data, "category_name", "categoryName"))
|
||
product_no = generate_auto_product_no(db, tenant_no)
|
||
category_name_val = category_name
|
||
if category_id and not category_name_val:
|
||
cat = get_category(db, category_id)
|
||
if cat:
|
||
category_name_val = cat.category_name
|
||
|
||
obj = CrmProduct(
|
||
id=generate_uuid(),
|
||
product_no=product_no,
|
||
product_name=product_name,
|
||
category_id=category_id,
|
||
category_name=category_name_val or "",
|
||
model=model,
|
||
unit=unit or None,
|
||
price=None,
|
||
status="on",
|
||
tenant_no=tenant_no,
|
||
create_by=user_id,
|
||
create_dept_id="",
|
||
del_flag=0,
|
||
)
|
||
db.add(obj)
|
||
db.flush()
|
||
return obj.id
|