Files
shanxia2.0/backend/app/crud/crm/product.py
T
zlwuandCursor 5c7210facc feat(crm): align product export with ListExportDropdown
Add ids/columns support and unify toolbar export UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 17:06:01 +08:00

1201 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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