|
|
@@ -12,13 +12,15 @@ from sqlalchemy.orm import Session
|
|
|
|
|
|
from app.core.config import settings
|
|
|
from app.core.utils import generate_uuid
|
|
|
-from app.models.crm_models import CrmProduct
|
|
|
+from app.models.crm_models import CrmProduct, CrmProductBrand
|
|
|
from app.models.db_models import DbInboundOutbound
|
|
|
from app.models.pl_models import (
|
|
|
PlInboundOrder,
|
|
|
PlInboundOrderItem,
|
|
|
+ PlInboundOrderItemBrandQty,
|
|
|
PlOutboundOrder,
|
|
|
PlOutboundOrderItem,
|
|
|
+ PlOutboundOrderItemBrandQty,
|
|
|
PlPlan,
|
|
|
PlPlanProduct,
|
|
|
PlPlanProductBrandQty,
|
|
|
@@ -27,6 +29,11 @@ from app.models.pl_models import (
|
|
|
)
|
|
|
from app.services.db.mappers import parse_date_str
|
|
|
from app.services.pl_dibang_bridge_service import _product_quantity, resolve_partner_company
|
|
|
+from app.services.pl_feedback_brand_qty import (
|
|
|
+ format_brand_qty_summary,
|
|
|
+ persist_io_item_brand_qtys,
|
|
|
+ require_brand_qtys_if_needed,
|
|
|
+)
|
|
|
|
|
|
|
|
|
OrderHeader = Union[PlInboundOrder, PlOutboundOrder]
|
|
|
@@ -309,15 +316,98 @@ def dump_feedback_images(images: list[str]) -> str:
|
|
|
_VALID_SITE_FEEDBACK_STATUSES = frozenset({"in_progress", "completed", "voided"})
|
|
|
|
|
|
|
|
|
+def _io_item_brand_qty_model(kind: str):
|
|
|
+ if kind == "inbound":
|
|
|
+ return PlInboundOrderItemBrandQty
|
|
|
+ return PlOutboundOrderItemBrandQty
|
|
|
+
|
|
|
+
|
|
|
+def update_io_order_items_qty_and_brand(
|
|
|
+ db: Session,
|
|
|
+ *,
|
|
|
+ kind: str,
|
|
|
+ tenant_no: str,
|
|
|
+ order_id: str,
|
|
|
+ items: list[OrderItem],
|
|
|
+ item_qty: dict[str, Decimal],
|
|
|
+ item_brand_qtys: Optional[dict[str, list[dict]]] = None,
|
|
|
+ enforce_brand: bool = False,
|
|
|
+ user_id: Optional[str] = None,
|
|
|
+) -> None:
|
|
|
+ """更新明细实际数量,并按需校验/持久化现场品牌数量。"""
|
|
|
+ updating = [it for it in items if it.id in item_qty]
|
|
|
+ if not updating:
|
|
|
+ return
|
|
|
+
|
|
|
+ plan_product_ids = [it.plan_product_id for it in updating if it.plan_product_id]
|
|
|
+ products_by_id: dict[str, PlPlanProduct] = {}
|
|
|
+ if plan_product_ids:
|
|
|
+ rows = (
|
|
|
+ db.query(PlPlanProduct)
|
|
|
+ .filter(
|
|
|
+ PlPlanProduct.id.in_(plan_product_ids),
|
|
|
+ PlPlanProduct.tenant_no == tenant_no,
|
|
|
+ PlPlanProduct.del_flag == 0,
|
|
|
+ )
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ products_by_id = {r.id: r for r in rows}
|
|
|
+
|
|
|
+ brand_model = _io_item_brand_qty_model(kind)
|
|
|
+
|
|
|
+ for item in updating:
|
|
|
+ actual_qty = item_qty[item.id]
|
|
|
+ plan_product = products_by_id.get(item.plan_product_id or "")
|
|
|
+ brand_mode = plan_product.brand_mode if plan_product else None
|
|
|
+ required_brand_id = plan_product.brand_id if plan_product else None
|
|
|
+
|
|
|
+ brand_touched = item_brand_qtys is not None and item.id in item_brand_qtys
|
|
|
+ if brand_touched:
|
|
|
+ rows = item_brand_qtys[item.id]
|
|
|
+ elif enforce_brand:
|
|
|
+ rows = []
|
|
|
+ else:
|
|
|
+ rows = None
|
|
|
+
|
|
|
+ if rows is not None:
|
|
|
+ require_brand_qtys_if_needed(
|
|
|
+ brand_mode=brand_mode,
|
|
|
+ required_brand_id=required_brand_id,
|
|
|
+ actual_qty=actual_qty,
|
|
|
+ rows=rows,
|
|
|
+ enforce=enforce_brand,
|
|
|
+ )
|
|
|
+
|
|
|
+ item.actual_qty = actual_qty
|
|
|
+ item.qty_source = "manual"
|
|
|
+ item.update_by = user_id
|
|
|
+
|
|
|
+ if brand_touched:
|
|
|
+ persist_io_item_brand_qtys(
|
|
|
+ db,
|
|
|
+ model=brand_model,
|
|
|
+ tenant_no=tenant_no,
|
|
|
+ order_id=order_id,
|
|
|
+ order_item_id=item.id,
|
|
|
+ plan_product_id=item.plan_product_id,
|
|
|
+ brand_mode=brand_mode,
|
|
|
+ rows=item_brand_qtys[item.id],
|
|
|
+ user_id=user_id,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
def apply_site_feedback(
|
|
|
db: Session,
|
|
|
*,
|
|
|
+ kind: str,
|
|
|
header: OrderHeader,
|
|
|
items: list[OrderItem],
|
|
|
item_qty: dict[str, Decimal],
|
|
|
remark: Optional[str],
|
|
|
images: list[str],
|
|
|
status: str,
|
|
|
+ item_brand_qtys: Optional[dict[str, list[dict]]] = None,
|
|
|
+ enforce_brand: bool = False,
|
|
|
user_id: Optional[str] = None,
|
|
|
) -> None:
|
|
|
if header.status == "voided":
|
|
|
@@ -325,12 +415,17 @@ def apply_site_feedback(
|
|
|
if status not in _VALID_SITE_FEEDBACK_STATUSES:
|
|
|
raise ValueError(f"无效状态: {status}")
|
|
|
now = datetime.now()
|
|
|
- for item in items:
|
|
|
- if item.id not in item_qty:
|
|
|
- continue
|
|
|
- item.actual_qty = item_qty[item.id]
|
|
|
- item.qty_source = "manual"
|
|
|
- item.update_by = user_id
|
|
|
+ update_io_order_items_qty_and_brand(
|
|
|
+ db,
|
|
|
+ kind=kind,
|
|
|
+ tenant_no=header.tenant_no,
|
|
|
+ order_id=header.id,
|
|
|
+ items=items,
|
|
|
+ item_qty=item_qty,
|
|
|
+ item_brand_qtys=item_brand_qtys,
|
|
|
+ enforce_brand=enforce_brand,
|
|
|
+ user_id=user_id,
|
|
|
+ )
|
|
|
header.site_feedback_remark = (remark or "").strip() or None
|
|
|
header.site_feedback_images = dump_feedback_images(images) if images else header.site_feedback_images
|
|
|
header.site_feedback_time = now
|
|
|
@@ -398,6 +493,7 @@ def merge_plan_product_into_item(
|
|
|
if not product:
|
|
|
item.setdefault("productNo", None)
|
|
|
item.setdefault("packageSpec", None)
|
|
|
+ item.setdefault("deliveryMethod", None)
|
|
|
item.setdefault("remark", None)
|
|
|
item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit"))
|
|
|
return
|
|
|
@@ -409,10 +505,131 @@ def merge_plan_product_into_item(
|
|
|
resolved_no = plan_no
|
|
|
item["productNo"] = resolved_no or None
|
|
|
item["packageSpec"] = product.package_spec
|
|
|
+ item["deliveryMethod"] = product.delivery_method
|
|
|
item["remark"] = (product.remark or "").strip() or None
|
|
|
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
|
|
|
+
|
|
|
+
|
|
|
+def _format_io_product_summary_line(item: dict) -> str:
|
|
|
+ name = (item.get("productName") or "").strip()
|
|
|
+ if not name:
|
|
|
+ return ""
|
|
|
+ bits: list[str] = []
|
|
|
+ brand = (item.get("brandSummary") or item.get("brandName") or "").strip()
|
|
|
+ if brand:
|
|
|
+ bits.append(f"品牌 {brand}")
|
|
|
+ spec = (item.get("specification") or "").strip()
|
|
|
+ if spec:
|
|
|
+ bits.append(f"型号/规格 {spec}")
|
|
|
+ unit = (item.get("unitLabel") or item.get("unit") or "").strip()
|
|
|
+ if unit:
|
|
|
+ bits.append(f"单位 {unit}")
|
|
|
+ package = (item.get("packageSpec") or "").strip()
|
|
|
+ if package:
|
|
|
+ bits.append(f"包装规格 {package}")
|
|
|
+ delivery = (item.get("deliveryMethod") or "").strip()
|
|
|
+ if delivery:
|
|
|
+ delivery_labels = {
|
|
|
+ "self_pick": "山峡自提",
|
|
|
+ "one_ticket": "一票制",
|
|
|
+ "customer_pick": "客户自提",
|
|
|
+ }
|
|
|
+ bits.append(f"送货方式 {delivery_labels.get(delivery, delivery)}")
|
|
|
+ return f"{name},{','.join(bits)}" if bits else name
|
|
|
+
|
|
|
+
|
|
|
+def enrich_io_items_with_brand_feedback(
|
|
|
+ db: Session,
|
|
|
+ *,
|
|
|
+ kind: str,
|
|
|
+ tenant_no: str,
|
|
|
+ items: list[dict],
|
|
|
+ products_by_id: dict[str, PlPlanProduct],
|
|
|
+ prefer_site_brand_summary: bool = False,
|
|
|
+) -> None:
|
|
|
+ """读时附加品牌模式、CRM 品牌选项与现场品牌数量子表。"""
|
|
|
+ if not items:
|
|
|
+ return
|
|
|
+
|
|
|
+ crm_product_ids: set[str] = set()
|
|
|
+ order_item_ids: list[str] = []
|
|
|
+ for it in items:
|
|
|
+ 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()
|
|
|
+ if product_id:
|
|
|
+ crm_product_ids.add(product_id)
|
|
|
+
|
|
|
+ brand_options_by_product: dict[str, list[dict]] = {}
|
|
|
+ if crm_product_ids:
|
|
|
+ brand_rows = (
|
|
|
+ db.query(CrmProductBrand)
|
|
|
+ .filter(
|
|
|
+ CrmProductBrand.product_id.in_(list(crm_product_ids)),
|
|
|
+ CrmProductBrand.tenant_no == tenant_no,
|
|
|
+ CrmProductBrand.status == "on",
|
|
|
+ CrmProductBrand.del_flag == 0,
|
|
|
+ )
|
|
|
+ .order_by(CrmProductBrand.order_num.asc(), CrmProductBrand.create_time.asc())
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ for row in brand_rows:
|
|
|
+ brand_options_by_product.setdefault(row.product_id, []).append(
|
|
|
+ {"brandId": row.id, "brandName": row.brand_name}
|
|
|
+ )
|
|
|
+
|
|
|
+ site_rows_by_item: dict[str, list[dict]] = {}
|
|
|
+ if order_item_ids:
|
|
|
+ brand_model = _io_item_brand_qty_model(kind)
|
|
|
+ site_rows = (
|
|
|
+ db.query(brand_model)
|
|
|
+ .filter(
|
|
|
+ brand_model.order_item_id.in_(order_item_ids),
|
|
|
+ brand_model.tenant_no == tenant_no,
|
|
|
+ brand_model.del_flag == 0,
|
|
|
+ )
|
|
|
+ .order_by(brand_model.order_num.asc())
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ for row in site_rows:
|
|
|
+ site_rows_by_item.setdefault(row.order_item_id, []).append(
|
|
|
+ {
|
|
|
+ "brandId": row.brand_id or "",
|
|
|
+ "brandName": row.brand_name,
|
|
|
+ "qty": row.qty,
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ 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()
|
|
|
+
|
|
|
+ if pp:
|
|
|
+ it["brandMode"] = pp.brand_mode
|
|
|
+ it["brandId"] = pp.brand_id
|
|
|
+ if pp.brand_name:
|
|
|
+ it["brandName"] = pp.brand_name
|
|
|
+ else:
|
|
|
+ it.setdefault("brandMode", None)
|
|
|
+ it.setdefault("brandId", None)
|
|
|
+
|
|
|
+ options = brand_options_by_product.get(product_id, [])
|
|
|
+ it["brandOptions"] = options if options else None
|
|
|
+
|
|
|
+ site_qtys = site_rows_by_item.get(it.get("id") or "", [])
|
|
|
+ it["brandQtys"] = site_qtys if site_qtys else None
|
|
|
+
|
|
|
+ if prefer_site_brand_summary:
|
|
|
+ site_summary = format_brand_qty_summary(site_qtys)
|
|
|
+ if site_summary:
|
|
|
+ it["brandSummary"] = site_summary
|
|
|
|
|
|
|
|
|
def enrich_io_order_dict(
|
|
|
@@ -423,6 +640,7 @@ def enrich_io_order_dict(
|
|
|
plan_id: Optional[str],
|
|
|
items: list[Any] | None,
|
|
|
public_unit_as_label: bool = False,
|
|
|
+ kind: str = "inbound",
|
|
|
) -> dict:
|
|
|
"""读时附加计划取送货与产品扩展字段。"""
|
|
|
from app.utils.excel_export import load_dict_label_maps
|
|
|
@@ -528,7 +746,26 @@ def enrich_io_order_dict(
|
|
|
|
|
|
for it in raw_items:
|
|
|
pp_id = it.get("planProductId") or ""
|
|
|
- it["brandSummary"] = brand_summary_by_pp.get(pp_id) or None
|
|
|
+ brand_summary = brand_summary_by_pp.get(pp_id) or None
|
|
|
+ if brand_summary:
|
|
|
+ it["brandSummary"] = brand_summary
|
|
|
+ elif it.get("brandName"):
|
|
|
+ it["brandSummary"] = it.get("brandName")
|
|
|
+ else:
|
|
|
+ it["brandSummary"] = None
|
|
|
+
|
|
|
+ enrich_io_items_with_brand_feedback(
|
|
|
+ db,
|
|
|
+ kind=kind,
|
|
|
+ tenant_no=tenant_no,
|
|
|
+ items=raw_items,
|
|
|
+ products_by_id=products_by_id,
|
|
|
+ prefer_site_brand_summary=public_unit_as_label,
|
|
|
+ )
|
|
|
+
|
|
|
+ # 列表摘要:产品名 + 品牌/规格/单位/包装/送货方式
|
|
|
+ summary_lines = [_format_io_product_summary_line(it) for it in raw_items]
|
|
|
+ data["productSummary"] = ";".join(line for line in summary_lines if line)[:800]
|
|
|
|
|
|
return data
|
|
|
|
|
|
@@ -592,5 +829,6 @@ def order_header_to_dict(
|
|
|
plan_id=row.plan_id,
|
|
|
items=items,
|
|
|
public_unit_as_label=public_unit_as_label,
|
|
|
+ kind=kind,
|
|
|
)
|
|
|
return data
|