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>
This commit is contained in:
zlwuandCursor committed 2026-07-31 07:06:41 +08:00
1 parent d1daedc17d
commit 5a9abfdd5d
6 files changed
+220 -54

No files matched your search

+10 -1
View File
@@ -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
View File
@@ -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"))
@@ -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"}
@@ -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">
@@ -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 []
@@ -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 []