diff --git a/backend/app/api/pl/io_order.py b/backend/app/api/pl/io_order.py index 1ce10e089..1b1ae7db0 100644 --- a/backend/app/api/pl/io_order.py +++ b/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) diff --git a/backend/app/api/pl/public_io_order.py b/backend/app/api/pl/public_io_order.py index e930af6c3..6b5815387 100644 --- a/backend/app/api/pl/public_io_order.py +++ b/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)) diff --git a/backend/app/schemas/pl/io_order.py b/backend/app/schemas/pl/io_order.py index 70e59172f..68cebd47b 100644 --- a/backend/app/schemas/pl/io_order.py +++ b/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} diff --git a/backend/app/services/pl_io_order_service.py b/backend/app/services/pl_io_order_service.py index f1b67901c..d38d212b9 100644 --- a/backend/app/services/pl_io_order_service.py +++ b/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