From 5a9abfdd5d7e303bb129cdc99bfc68a1d4271d82 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 31 Jul 2026 07:06:41 +0800 Subject: [PATCH] fix(pl): keep multi-brand site feedback when plan product row is stale Persist submitted brand qtys even if plan brand_mode lookup fails; resolve soft-deleted plan products for brand metadata instead of substituting another product; stop seeding all actual qty onto the first brand option. Co-authored-by: Cursor --- backend/app/services/pl_feedback_brand_qty.py | 11 +- backend/app/services/pl_io_order_service.py | 119 ++++++++++++++++-- backend/tests/test_pl_feedback_brand_qty.py | 53 ++++++++ .../business/brand-qty-chips-field/index.vue | 22 +--- .../public/pl/_shared/PublicIoOrderPage.vue | 35 ++++-- .../src/views/public/pl/transport/index.vue | 34 +++-- 6 files changed, 220 insertions(+), 54 deletions(-) diff --git a/backend/app/services/pl_feedback_brand_qty.py b/backend/app/services/pl_feedback_brand_qty.py index a44927b5d..bc764c4fe 100644 --- a/backend/app/services/pl_feedback_brand_qty.py +++ b/backend/app/services/pl_feedback_brand_qty.py @@ -68,7 +68,9 @@ def persist_io_item_brand_qtys( row.del_flag = 1 row.update_by = user_id - if not brand_mode: + # 客户端显式提交了品牌明细时必须落库;勿因计划行 brand_mode 缺失/软删查找失败而只删不写 + positive = [item for item in rows if Decimal(str(item.get("qty") or 0)) > 0] + if not brand_mode and not positive: return for idx, item in enumerate(rows): @@ -287,6 +289,13 @@ def load_transport_driver_product_feedbacks( { "planProductId": fb.plan_product_id, "productName": (pp.product_name if pp else None) or "", + "productNo": (pp.product_no if pp else None) or None, + "model": (pp.model if pp else None) or None, + "unit": (pp.unit if pp else None) or None, + "packageSpec": (pp.package_spec if pp else None) or None, + "brandMode": (pp.brand_mode if pp else None) or None, + "brandId": (pp.brand_id if pp else None) or None, + "brandName": (pp.brand_name if pp else None) or None, "actualQty": fb.actual_qty, "brandQtys": brand_qtys or None, "brandSummary": format_brand_qty_summary(brand_qtys), diff --git a/backend/app/services/pl_io_order_service.py b/backend/app/services/pl_io_order_service.py index d38d212b9..5e06da8dc 100644 --- a/backend/app/services/pl_io_order_service.py +++ b/backend/app/services/pl_io_order_service.py @@ -34,6 +34,7 @@ from app.services.pl_feedback_brand_qty import ( persist_io_item_brand_qtys, require_brand_qtys_if_needed, ) +from app.services.pl_product_display import apply_product_alias_display, load_crm_product_alias_maps OrderHeader = Union[PlInboundOrder, PlOutboundOrder] @@ -352,6 +353,19 @@ def update_io_order_items_qty_and_brand( .all() ) products_by_id = {r.id: r for r in rows} + # 明细仍指向已软删计划产品行时,用原行品牌约定(避免 brand_mode 空导致只删不写) + missing_ids = [pid for pid in plan_product_ids if pid and pid not in products_by_id] + if missing_ids: + stale_rows = ( + db.query(PlPlanProduct) + .filter( + PlPlanProduct.id.in_(missing_ids), + PlPlanProduct.tenant_no == tenant_no, + ) + .all() + ) + for row in stale_rows: + products_by_id.setdefault(row.id, row) brand_model = _io_item_brand_qty_model(kind) @@ -364,6 +378,9 @@ def update_io_order_items_qty_and_brand( brand_touched = item_brand_qtys is not None and item.id in item_brand_qtys if brand_touched: rows = item_brand_qtys[item.id] + # 客户端带了品牌明细时,即使计划行 brand_mode 丢失也按 any 规则校验/落库 + if not brand_mode and rows: + brand_mode = "any" elif enforce_brand: rows = [] else: @@ -492,27 +509,34 @@ def merge_plan_product_into_item( ) -> None: if not product: item.setdefault("productNo", None) + item.setdefault("categoryName", None) item.setdefault("packageSpec", None) item.setdefault("deliveryMethod", None) item.setdefault("remark", None) item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit")) return # 优先 CRM 产品编码;计划冗余 product_no 可能被误存为 product_id - product_id = (product.product_id or item.get("productId") or "").strip() + product_id = (product.product_id or item.get("productId") or item.get("product_id") or "").strip() plan_no = (product.product_no or "").strip() resolved_no = (crm_product_no or "").strip() if not resolved_no and plan_no and plan_no != product_id: resolved_no = plan_no + # CRM 缺失时仍回退计划 product_no,避免前端编码列空白 + if not resolved_no and plan_no: + resolved_no = plan_no item["productNo"] = resolved_no or None - item["packageSpec"] = product.package_spec + item["categoryName"] = product.category_name + item["packageSpec"] = product.package_spec or item.get("packageSpec") item["deliveryMethod"] = product.delivery_method - item["remark"] = (product.remark or "").strip() or None + item["remark"] = (product.remark or "").strip() or item.get("remark") if not item.get("specification") and product.model: item["specification"] = product.model item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit") or product.unit) # 无品牌拆分量时,回退计划产品上的品牌快照 if product.brand_name and not item.get("brandSummary"): item["brandName"] = product.brand_name + if product.brand_mode: + item.setdefault("brandMode", product.brand_mode) def _format_io_product_summary_line(item: dict) -> str: @@ -562,8 +586,11 @@ def enrich_io_items_with_brand_feedback( item_id = (it.get("id") or "").strip() if item_id: order_item_ids.append(item_id) - pp = products_by_id.get(it.get("planProductId") or "") - product_id = ((pp.product_id if pp else None) or it.get("productId") or "").strip() + plan_product_id = (it.get("planProductId") or it.get("plan_product_id") or "").strip() + pp = products_by_id.get(plan_product_id) + product_id = ( + (pp.product_id if pp else None) or it.get("productId") or it.get("product_id") or "" + ).strip() if product_id: crm_product_ids.add(product_id) @@ -608,8 +635,11 @@ def enrich_io_items_with_brand_feedback( ) for it in items: - pp = products_by_id.get(it.get("planProductId") or "") - product_id = ((pp.product_id if pp else None) or it.get("productId") or "").strip() + plan_product_id = (it.get("planProductId") or it.get("plan_product_id") or "").strip() + pp = products_by_id.get(plan_product_id) + product_id = ( + (pp.product_id if pp else None) or it.get("productId") or it.get("product_id") or "" + ).strip() if pp: it["brandMode"] = pp.brand_mode @@ -667,9 +697,9 @@ def enrich_io_order_dict( return data product_ids = [ - it.get("planProductId") + (it.get("planProductId") or it.get("plan_product_id") or "").strip() for it in raw_items - if it.get("planProductId") + if (it.get("planProductId") or it.get("plan_product_id") or "").strip() ] products_by_id: dict[str, PlPlanProduct] = {} if product_ids: @@ -683,6 +713,39 @@ def enrich_io_order_dict( .all() ) products_by_id = {r.id: r for r in rows} + # 明细仍挂已软删计划产品时,保留原行用于品牌约定与 CRM 产品定位(勿误用其它在用产品) + missing_ids = [pid for pid in product_ids if pid and pid not in products_by_id] + if missing_ids: + stale_rows = ( + db.query(PlPlanProduct) + .filter( + PlPlanProduct.id.in_(missing_ids), + PlPlanProduct.tenant_no == tenant_no, + ) + .all() + ) + for row in stale_rows: + products_by_id.setdefault(row.id, row) + + # 计划产品行可能被编辑重建导致 plan_product_id 失效:按计划再拉全量做 product_id / 序号兜底 + plan_products: list[PlPlanProduct] = [] + products_by_crm_id: dict[str, PlPlanProduct] = {} + if plan_id: + plan_products = ( + db.query(PlPlanProduct) + .filter( + PlPlanProduct.plan_id == plan_id, + PlPlanProduct.tenant_no == tenant_no, + PlPlanProduct.del_flag == 0, + ) + .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc()) + .all() + ) + for row in plan_products: + products_by_id.setdefault(row.id, row) + crm_key = (row.product_id or "").strip() + if crm_key and crm_key not in products_by_crm_id: + products_by_crm_id[crm_key] = row crm_ids = { (pp.product_id or "").strip() @@ -690,7 +753,7 @@ def enrich_io_order_dict( if (pp.product_id or "").strip() } for it in raw_items: - pid = (it.get("productId") or "").strip() + pid = (it.get("productId") or it.get("product_id") or "").strip() if pid: crm_ids.add(pid) crm_no_by_id: dict[str, str] = {} @@ -709,15 +772,45 @@ def enrich_io_order_dict( if no: crm_no_by_id[row.id] = no - for it in raw_items: - pp = products_by_id.get(it.get("planProductId") or "") - product_id = ((pp.product_id if pp else None) or it.get("productId") or "").strip() + standard_by_id, alias_options_by_id = load_crm_product_alias_maps( + db, tenant_no=tenant_no, product_ids=crm_ids + ) + + for idx, it in enumerate(raw_items): + plan_product_id = (it.get("planProductId") or it.get("plan_product_id") or "").strip() + if plan_product_id and not it.get("planProductId"): + it["planProductId"] = plan_product_id + pp = products_by_id.get(plan_product_id) if plan_product_id else None + # 仅在明细未挂 planProductId 时用 CRM/序号兜底;已挂 id 时勿换成其它在用产品(会错带指定品牌) + if not pp and not plan_product_id: + crm_key = (it.get("productId") or it.get("product_id") or "").strip() + if crm_key: + pp = products_by_crm_id.get(crm_key) + if not pp and len(plan_products) == len(raw_items): + pp = plan_products[idx] + if pp and not it.get("planProductId"): + it["planProductId"] = pp.id + product_id = ( + (pp.product_id if pp else None) or it.get("productId") or it.get("product_id") or "" + ).strip() + # 优先用计划产品已存名(可能是别名)做拆分;明细行 productName 同步 + saved_name = (pp.product_name if pp else None) or it.get("productName") or it.get( + "product_name" + ) + if saved_name: + it["productName"] = saved_name merge_plan_product_into_item( it, pp, unit_map=unit_map, crm_product_no=crm_no_by_id.get(product_id), ) + apply_product_alias_display( + it, + product_id=product_id, + standard_by_id=standard_by_id, + alias_options_by_id=alias_options_by_id, + ) if public_unit_as_label: it["unit"] = it.get("unitLabel") or resolve_unit_label(unit_map, it.get("unit")) diff --git a/backend/tests/test_pl_feedback_brand_qty.py b/backend/tests/test_pl_feedback_brand_qty.py index 30716bf53..f6e2d0303 100644 --- a/backend/tests/test_pl_feedback_brand_qty.py +++ b/backend/tests/test_pl_feedback_brand_qty.py @@ -73,3 +73,56 @@ def test_require_calls_validate_when_positive_rows(monkeypatch): assert called["brand_mode"] == "any" assert called["actual_qty"] == Decimal("10") assert called["rows"] == rows + + +def test_persist_io_item_brand_qtys_writes_when_brand_mode_missing(): + """计划行 brand_mode 丢失时,客户端提交的多品牌明细仍应落库(不能只软删不写)。""" + from app.services.pl_feedback_brand_qty import persist_io_item_brand_qtys + + class _Row: + def __init__(self): + self.del_flag = 0 + self.update_by = None + + existing = [_Row()] + added = [] + + class _Query: + def filter(self, *args, **kwargs): + return self + + def all(self): + return existing + + class _Db: + def query(self, model): + return _Query() + + def add(self, obj): + added.append(obj) + + class _Model: + order_item_id = object() + tenant_no = object() + del_flag = object() + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + persist_io_item_brand_qtys( + _Db(), + model=_Model, + tenant_no="t1", + order_id="o1", + order_item_id="i1", + plan_product_id="p1", + brand_mode=None, + rows=[ + {"brandId": "b1", "brandName": "君正", "qty": Decimal("20")}, + {"brandId": "b2", "brandName": "红四方", "qty": Decimal("30")}, + ], + user_id="u1", + ) + assert existing[0].del_flag == 1 + assert len(added) == 2 + assert {a.brand_id for a in added} == {"b1", "b2"} diff --git a/web-admin/src/components/business/brand-qty-chips-field/index.vue b/web-admin/src/components/business/brand-qty-chips-field/index.vue index 86a12c65e..71ebce450 100644 --- a/web-admin/src/components/business/brand-qty-chips-field/index.vue +++ b/web-admin/src/components/business/brand-qty-chips-field/index.vue @@ -201,27 +201,7 @@ setQty(brandId, normalized) } - watch( - () => [props.brandMode, props.requiredBrandId, props.brandOptions] as const, - () => { - if (props.brandMode !== 'required') return - const id = props.requiredBrandId || props.brandOptions[0]?.brandId - if (!id) return - const opt = props.brandOptions.find((o) => o.brandId === id) - const existing = props.modelValue.find((r) => r.brandId === id) - if (!existing) { - emitRows([ - ...props.modelValue, - { - brandId: id, - brandName: opt?.brandName || '指定品牌', - qty: 0 - } - ]) - } - }, - { immediate: true } - ) + // 偏好品牌预选由父级初始化;组件内不再强制回填,避免取消后被再次插入