瀏覽代碼

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 <cursoragent@cursor.com>
zlwu 3 月之前
父節點
當前提交
5a9abfdd5d

+ 10 - 1
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),

+ 106 - 13
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"))
 

+ 53 - 0
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"}

+ 1 - 21
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 }
-  )
+  // 偏好品牌预选由父级初始化;组件内不再强制回填,避免取消后被再次插入
 </script>
 
 <style scoped lang="scss">

+ 26 - 9
web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue

@@ -80,6 +80,14 @@
               <span class="product-card__badge">产品 {{ idx + 1 }}/{{ editableItems.length }}</span>
             </div>
             <div class="meta-grid">
+              <div class="meta-item">
+                <div class="meta-item__label">别名</div>
+                <div class="meta-item__value">{{ item.aliasName || '—' }}</div>
+              </div>
+              <div class="meta-item">
+                <div class="meta-item__label">品牌</div>
+                <div class="meta-item__value">{{ formatBrandLabel(item) }}</div>
+              </div>
               <div class="meta-item">
                 <div class="meta-item__label">规格型号</div>
                 <div class="meta-item__value">{{ item.specification || '—' }}</div>
@@ -191,6 +199,7 @@
   import BrandQtyChipsField from '@/components/business/brand-qty-chips-field/index.vue'
   import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
   import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
+  import { resolvePlanBrandDisplayLabel } from '@/utils/pl-display'
 
   import 'vant/es/dialog/style'
   import 'vant/es/toast/style'
@@ -262,6 +271,10 @@
     return unit ? `${qty} ${unit}` : String(qty)
   }
 
+  function formatBrandLabel(item: PublicIoOrderItem) {
+    return resolvePlanBrandDisplayLabel(item) || '—'
+  }
+
   function statusLabel(s?: string) {
     const map: Record<string, string> = {
       in_progress: '进行中',
@@ -288,19 +301,23 @@
   }
 
   function mapBrandQtyRows(it: PublicIoOrderItem): BrandQtyItem[] {
-    const existing = (it.brandQtys || []).map((row) => ({
-      brandId: row.brandId,
-      brandName: row.brandName || '',
-      qty: Number(row.qty ?? 0)
-    }))
+    const existing = (it.brandQtys || [])
+      .filter((row) => Number(row.qty ?? 0) > 0)
+      .map((row) => ({
+        brandId: row.brandId,
+        brandName: row.brandName || '',
+        qty: Number(row.qty ?? 0)
+      }))
     if (existing.length) return existing
 
-    if (it.brandMode === 'required') {
-      const brandId = it.brandId || it.brandOptions?.[0]?.brandId || ''
+    // 指定品牌:仅预选计划上的 brandId,禁止把总量塞到 options 第一项
+    if (it.brandMode === 'required' && it.brandId) {
       const brandName =
-        it.brandName || it.brandOptions?.find((o) => o.brandId === brandId)?.brandName || '指定品牌'
+        it.brandName ||
+        it.brandOptions?.find((o) => o.brandId === it.brandId)?.brandName ||
+        '指定品牌'
       const seedQty = it.actualQty != null ? Number(it.actualQty) : 0
-      return brandId ? [{ brandId, brandName, qty: seedQty }] : []
+      return [{ brandId: it.brandId, brandName, qty: seedQty }]
     }
 
     return []

+ 24 - 10
web-admin/src/views/public/pl/transport/index.vue

@@ -98,6 +98,14 @@
               </span>
             </div>
             <div class="meta-grid">
+              <div class="meta-item">
+                <div class="meta-item__label">别名</div>
+                <div class="meta-item__value">{{ item.aliasName || '—' }}</div>
+              </div>
+              <div class="meta-item">
+                <div class="meta-item__label">品牌</div>
+                <div class="meta-item__value">{{ formatBrandLabel(item) }}</div>
+              </div>
               <div class="meta-item">
                 <div class="meta-item__label">规格型号</div>
                 <div class="meta-item__value">{{ item.model || '—' }}</div>
@@ -205,7 +213,7 @@
   import BrandQtyChipsField from '@/components/business/brand-qty-chips-field/index.vue'
   import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
   import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
-  import { formatPlPersonnelNameWithRole } from '@/utils/pl-display'
+  import { formatPlPersonnelNameWithRole, resolvePlanBrandDisplayLabel } from '@/utils/pl-display'
 
   import 'vant/es/toast/style'
   import '@/styles/public-mobile-vant.scss'
@@ -290,6 +298,10 @@
     return unit ? `${qty} ${unit}` : String(qty)
   }
 
+  function formatBrandLabel(item: PublicTransportProduct) {
+    return resolvePlanBrandDisplayLabel(item) || '—'
+  }
+
   function resolveFileUrl(url: string) {
     if (!url) return ''
     if (/^https?:\/\//i.test(url)) return url
@@ -307,21 +319,23 @@
   }
 
   function mapBrandQtyRows(prod: PublicTransportProduct): BrandQtyItem[] {
-    const existing = (prod.brandQtys || []).map((row) => ({
-      brandId: row.brandId,
-      brandName: row.brandName || '',
-      qty: Number(row.qty ?? 0)
-    }))
+    const existing = (prod.brandQtys || [])
+      .filter((row) => Number(row.qty ?? 0) > 0)
+      .map((row) => ({
+        brandId: row.brandId,
+        brandName: row.brandName || '',
+        qty: Number(row.qty ?? 0)
+      }))
     if (existing.length) return existing
 
-    if (prod.brandMode === 'required') {
-      const brandId = prod.brandId || prod.brandOptions?.[0]?.brandId || ''
+    // 指定品牌:仅预选计划上的 brandId,禁止把总量塞到 options 第一项
+    if (prod.brandMode === 'required' && prod.brandId) {
       const brandName =
         prod.brandName ||
-        prod.brandOptions?.find((o) => o.brandId === brandId)?.brandName ||
+        prod.brandOptions?.find((o) => o.brandId === prod.brandId)?.brandName ||
         '指定品牌'
       const seedQty = prod.actualQty != null ? Number(prod.actualQty) : 0
-      return brandId ? [{ brandId, brandName, qty: seedQty }] : []
+      return [{ brandId: prod.brandId, brandName, qty: seedQty }]
     }
 
     return []