feat(pl): site io-order feedback accepts and stores brand qtys
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
b0346e688d
commit
006ed6586c
4 files changed
+303
-11
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user