feat(crm): persist product brands and aliases on create/update

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-28 09:21:31 +08:00
1 parent ea43150b8a
commit bfa0d50597
3 files changed
+318 -7

No files matched your search

+4 -4
View File
@@ -34,7 +34,6 @@ from app.utils.crm_export_columns import (
from app.utils.excel_export import export_rows_to_xlsx_response
from app.crud.crm.product import (
_category_row_to_dict,
_product_row_to_dict,
create_category,
create_product,
get_category,
@@ -44,6 +43,7 @@ from app.crud.crm.product import (
list_product_options,
list_products,
product_no_exists,
product_to_dict,
soft_delete_category,
soft_delete_product,
total_pages,
@@ -624,7 +624,7 @@ def product_detail(
if not obj:
return error_response(404, "产品不存在")
ensure_crm_create_by_row_in_scope(db, obj, user_info, tenant_no, message="无权访问该产品")
return success_response(_product_row_to_dict(obj))
return success_response(product_to_dict(db, obj))
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
@@ -648,7 +648,7 @@ def product_add(
user_id=user_id,
**body.model_dump(exclude_unset=True),
)
return success_response(_product_row_to_dict(obj), message="创建成功")
return success_response(product_to_dict(db, obj), message="创建成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
@@ -669,7 +669,7 @@ def product_update(
tenant_no = get_tenant_no(db, _user)
user_info = crm_user_info(_user)
obj = update_product(db, product_id, user_id=user_id, user_info=user_info, **data)
return success_response(_product_row_to_dict(obj), message="更新成功")
return success_response(product_to_dict(db, obj), message="更新成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
+289 -3
View File
@@ -15,7 +15,15 @@ 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, CrmProductCategory, SysOss
from app.models.crm_models import (
CrmProduct,
CrmProductAlias,
CrmProductBrand,
CrmProductCategory,
SysOss,
)
from app.models.pl_models import PlInventory, PlPlan, PlPlanProduct
from fastapi import HTTPException
# ── 工具函数 ──────────────────────────────────────────────────
@@ -282,7 +290,232 @@ def category_code_exists(
# ── 产品 CRUD ────────────────────────────────────────────────
def _product_row_to_dict(row: CrmProduct) -> Dict[str, Any]:
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 {
@@ -308,11 +541,23 @@ def _product_row_to_dict(row: CrmProduct) -> Dict[str, Any]:
"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,
@@ -375,7 +620,16 @@ def list_products(
.limit(page_size)
.all()
)
return [_product_row_to_dict(r) for r in rows], int(total)
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]:
@@ -525,6 +779,21 @@ def create_product(
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
@@ -585,6 +854,23 @@ def update_product(
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()
+25
View File
@@ -76,6 +76,27 @@ class ProductListBody(BaseModel):
supplier_id: Optional[str] = Field(default=None, alias="supplierId", description="报价/合同主体ID,传入则按危化品能力过滤")
class ProductBrandItem(BaseModel):
"""产品品牌项"""
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
brand_name: str = Field(..., alias="brandName", max_length=100, description="品牌名称")
status: str = Field(default="on", description="状态 on/off")
order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号")
class ProductAliasItem(BaseModel):
"""产品别名项"""
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
alias_name: str = Field(..., alias="aliasName", max_length=200, description="别名")
order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号")
class ProductCreateBody(BaseModel):
"""产品新增请求体"""
@@ -106,6 +127,8 @@ class ProductCreateBody(BaseModel):
sale_count: int = Field(default=0, alias="saleCount", description="销售次数")
order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号")
remark: Optional[str] = Field(default=None, description="备注")
brands: Optional[List[ProductBrandItem]] = Field(default=None, description="产品品牌列表")
aliases: Optional[List[ProductAliasItem]] = Field(default=None, description="产品别名列表")
class ProductUpdateBody(BaseModel):
@@ -139,6 +162,8 @@ class ProductUpdateBody(BaseModel):
sale_count: Optional[int] = Field(default=None, alias="saleCount", description="销售次数")
order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号")
remark: Optional[str] = Field(default=None, description="备注")
brands: Optional[List[ProductBrandItem]] = Field(default=None, description="产品品牌列表")
aliases: Optional[List[ProductAliasItem]] = Field(default=None, description="产品别名列表")
class ProductDetailBody(BaseModel):