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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-31 05:17:50 +08:00
1 parent b0346e688d
commit 006ed6586c
4 files changed
+303 -11

No files matched your search

+25 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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