Просмотр исходного кода

feat(pl): site io-order feedback accepts and stores brand qtys

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
006ed6586c

+ 25 - 3
backend/app/api/pl/io_order.py

@@ -22,6 +22,7 @@ from app.schemas.pl.io_order import IoOrderListResponse, IoOrderPatch, IoOrderRe
 from app.services.pl_io_order_service import (
     dump_feedback_images,
     order_header_to_dict,
+    update_io_order_items_qty_and_brand,
 )
 
 inbound_router = APIRouter(prefix="/inbound", tags=["运输计划-入库单"])
@@ -138,13 +139,34 @@ def _patch_order(
         header.remark = payload.remark
     if payload.items:
         by_id = {it.id: it for it in items}
+        qty_map: dict[str, Decimal] = {}
+        brand_qty_map: dict[str, list[dict]] | None = None
         for patch in payload.items:
             row = by_id.get(patch.id)
             if not row:
                 continue
-            row.actual_qty = Decimal(str(patch.actual_qty))
-            row.qty_source = "manual"
-            row.update_by = user.id
+            qty_map[patch.id] = Decimal(str(patch.actual_qty))
+            if patch.brand_qtys is not None:
+                if brand_qty_map is None:
+                    brand_qty_map = {}
+                brand_qty_map[patch.id] = [
+                    b.model_dump(by_alias=True) for b in patch.brand_qtys
+                ]
+        effective_status = payload.status if payload.status is not None else header.status
+        try:
+            update_io_order_items_qty_and_brand(
+                db,
+                kind=kind,
+                tenant_no=tenant_no,
+                order_id=header.id,
+                items=items,
+                item_qty=qty_map,
+                item_brand_qtys=brand_qty_map,
+                enforce_brand=(effective_status == "completed"),
+                user_id=user.id,
+            )
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
     header.update_by = user.id
     db.commit()
     header, items = _get_order(db, tenant_no, kind, order_id)

+ 11 - 0
backend/app/api/pl/public_io_order.py

@@ -82,18 +82,29 @@ def _make_handlers(kind: str):
         if page["voided"]:
             return error_response(400, "单据已作废,无法提交")
         qty_map = {x.id: Decimal(str(x.actual_qty)) for x in payload.items}
+        brand_qty_map: dict[str, list[dict]] | None = None
+        if any(x.brand_qtys is not None for x in payload.items):
+            brand_qty_map = {}
+            for x in payload.items:
+                if x.brand_qtys is not None:
+                    brand_qty_map[x.id] = [
+                        b.model_dump(by_alias=True) for b in x.brand_qtys
+                    ]
         images = payload.images
         if not images:
             images = parse_feedback_images(header.site_feedback_images)
         try:
             apply_site_feedback(
                 db,
+                kind=kind,
                 header=header,
                 items=items,
                 item_qty=qty_map,
                 remark=payload.remark,
                 images=images,
                 status=payload.status,
+                item_brand_qtys=brand_qty_map,
+                enforce_brand=(payload.status == "completed"),
             )
         except ValueError as exc:
             return error_response(400, str(exc))

+ 21 - 0
backend/app/schemas/pl/io_order.py

@@ -8,6 +8,19 @@ from typing import Optional
 from pydantic import BaseModel, Field
 
 
+class BrandOption(BaseModel):
+    brand_id: str = Field(alias="brandId")
+    brand_name: str = Field(alias="brandName")
+    model_config = {"populate_by_name": True}
+
+
+class BrandQtyItem(BaseModel):
+    brand_id: str = Field(default="", alias="brandId")
+    brand_name: Optional[str] = Field(default=None, alias="brandName")
+    qty: Decimal = Field(..., ge=0)
+    model_config = {"populate_by_name": True}
+
+
 class IoOrderItemRead(BaseModel):
     id: str
     order_id: str = Field(alias="orderId")
@@ -15,6 +28,11 @@ class IoOrderItemRead(BaseModel):
     product_id: Optional[str] = Field(default=None, alias="productId")
     product_name: Optional[str] = Field(default=None, alias="productName")
     brand_summary: Optional[str] = Field(default=None, alias="brandSummary")
+    brand_mode: Optional[str] = Field(default=None, alias="brandMode")
+    brand_id: Optional[str] = Field(default=None, alias="brandId")
+    brand_name: Optional[str] = Field(default=None, alias="brandName")
+    brand_options: Optional[list[BrandOption]] = Field(default=None, alias="brandOptions")
+    brand_qtys: Optional[list[BrandQtyItem]] = Field(default=None, alias="brandQtys")
     specification: Optional[str] = None
     unit: Optional[str] = None
     plan_qty: Optional[Decimal] = Field(default=None, alias="planQty")
@@ -22,6 +40,7 @@ class IoOrderItemRead(BaseModel):
     qty_source: Optional[str] = Field(default=None, alias="qtySource")
     product_no: Optional[str] = Field(default=None, alias="productNo")
     package_spec: Optional[str] = Field(default=None, alias="packageSpec")
+    delivery_method: Optional[str] = Field(default=None, alias="deliveryMethod")
     remark: Optional[str] = None
     unit_label: Optional[str] = Field(default=None, alias="unitLabel")
     model_config = {"populate_by_name": True}
@@ -30,6 +49,7 @@ class IoOrderItemRead(BaseModel):
 class IoOrderItemPatch(BaseModel):
     id: str
     actual_qty: Decimal = Field(alias="actualQty")
+    brand_qtys: Optional[list[BrandQtyItem]] = Field(default=None, alias="brandQtys")
     model_config = {"populate_by_name": True}
 
 
@@ -89,6 +109,7 @@ class IoOrderListResponse(BaseModel):
 class PublicIoOrderItemFeedback(BaseModel):
     id: str
     actual_qty: Decimal = Field(alias="actualQty")
+    brand_qtys: Optional[list[BrandQtyItem]] = Field(default=None, alias="brandQtys")
     model_config = {"populate_by_name": True}
 
 

+ 246 - 8
backend/app/services/pl_io_order_service.py

@@ -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