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:
1 parent
d1daedc17d
commit
5a9abfdd5d
6 files changed
+220
-54
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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 []
|
||||
|
||||
Reference in new issue
Block a user