feat(crm): persist product brands and aliases on create/update
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
ea43150b8a
commit
bfa0d50597
3 files changed
+318
-7
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user