Persist submitted brand qtys even if plan brand_mode lookup fails; resolve soft-deleted plan products for brand metadata instead of substituting another product; stop seeding all actual qty onto the first brand option. Co-authored-by: Cursor <cursoragent@cursor.com>
928 lines
31 KiB
Python
928 lines
31 KiB
Python
"""运输计划 · 入库单 / 出库单(安排运输落单、现场回传、作废)"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import secrets
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
from typing import Any, Optional, Union
|
||
|
||
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, CrmProductBrand
|
||
from app.models.db_models import DbInboundOutbound
|
||
from app.models.pl_models import (
|
||
PlInboundOrder,
|
||
PlInboundOrderItem,
|
||
PlInboundOrderItemBrandQty,
|
||
PlOutboundOrder,
|
||
PlOutboundOrderItem,
|
||
PlOutboundOrderItemBrandQty,
|
||
PlPlan,
|
||
PlPlanProduct,
|
||
PlPlanProductBrandQty,
|
||
PlTransport,
|
||
PlTransportDbLink,
|
||
)
|
||
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,
|
||
)
|
||
from app.services.pl_product_display import apply_product_alias_display, load_crm_product_alias_maps
|
||
|
||
|
||
OrderHeader = Union[PlInboundOrder, PlOutboundOrder]
|
||
OrderItem = Union[PlInboundOrderItem, PlOutboundOrderItem]
|
||
|
||
|
||
@dataclass
|
||
class IoOrderCreateResult:
|
||
kind: str # inbound | outbound
|
||
order_id: str
|
||
order_no: str
|
||
site_access_token: str
|
||
site_url: str
|
||
|
||
|
||
def build_io_order_site_url(kind: str, token: str) -> str:
|
||
origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
|
||
path = "inbound" if kind == "inbound" else "outbound"
|
||
return f"{origin}/#/public/pl/{path}/{token}"
|
||
|
||
|
||
def _normalize_plan_date(plan: PlPlan) -> date:
|
||
plan_date = plan.plan_date
|
||
if isinstance(plan_date, datetime):
|
||
return plan_date.date()
|
||
if isinstance(plan_date, str):
|
||
return parse_date_str(plan_date) or date.today()
|
||
if isinstance(plan_date, date):
|
||
return plan_date
|
||
return date.today()
|
||
|
||
|
||
def _next_order_no(db: Session, tenant_no: str, prefix: str, model: type) -> str:
|
||
day = datetime.now().strftime("%Y%m%d")
|
||
like = f"{prefix}{day}%"
|
||
last = (
|
||
db.query(model.order_no)
|
||
.filter(model.tenant_no == tenant_no, model.order_no.like(like), model.del_flag == 0)
|
||
.order_by(model.order_no.desc())
|
||
.first()
|
||
)
|
||
seq = 1
|
||
if last and last[0] and len(last[0]) >= len(prefix) + 8 + 4:
|
||
try:
|
||
seq = int(last[0][-4:]) + 1
|
||
except ValueError:
|
||
seq = 1
|
||
return f"{prefix}{day}{seq:04d}"
|
||
|
||
|
||
def _dibang_qty_by_plan_product(
|
||
db: Session, tenant_no: str, transport_id: str
|
||
) -> dict[str, Decimal]:
|
||
links = (
|
||
db.query(PlTransportDbLink)
|
||
.filter(
|
||
PlTransportDbLink.tenant_no == tenant_no,
|
||
PlTransportDbLink.transport_id == transport_id,
|
||
PlTransportDbLink.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
if not links:
|
||
return {}
|
||
inv_ids = [x.db_inventory_id for x in links if x.db_inventory_id]
|
||
if not inv_ids:
|
||
return {}
|
||
inv_rows = (
|
||
db.query(DbInboundOutbound)
|
||
.filter(DbInboundOutbound.id.in_(inv_ids), DbInboundOutbound.del_flag == 0)
|
||
.all()
|
||
)
|
||
inv_map = {r.id: r for r in inv_rows}
|
||
out: dict[str, Decimal] = {}
|
||
for link in links:
|
||
inv = inv_map.get(link.db_inventory_id)
|
||
if not inv or inv.quantity is None:
|
||
continue
|
||
out[link.plan_product_id] = Decimal(str(inv.quantity))
|
||
return out
|
||
|
||
|
||
def create_io_order_for_transport(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan: PlPlan,
|
||
products: list[PlPlanProduct],
|
||
transport: PlTransport,
|
||
user_id: Optional[str],
|
||
dept_id: str,
|
||
after_dibang: bool = False,
|
||
) -> Optional[IoOrderCreateResult]:
|
||
"""安排运输后创建入库/出库单。直销返回 None。"""
|
||
if plan.plan_type not in ("inbound", "outbound"):
|
||
return None
|
||
|
||
kind = plan.plan_type
|
||
header_model = PlInboundOrder if kind == "inbound" else PlOutboundOrder
|
||
item_model = PlInboundOrderItem if kind == "inbound" else PlOutboundOrderItem
|
||
prefix = "RK" if kind == "inbound" else "CK"
|
||
|
||
partner = resolve_partner_company(plan, plan.plan_type)
|
||
if not partner:
|
||
raise ValueError("无法解析往来单位,请检查合同供方/需方信息")
|
||
|
||
token = secrets.token_urlsafe(32)[:64]
|
||
order_no = _next_order_no(db, tenant_no, prefix, header_model)
|
||
plan_date = _normalize_plan_date(plan)
|
||
license_plate = (transport.license_plate or plan.license_plate or "").strip()
|
||
dibang_map = _dibang_qty_by_plan_product(db, tenant_no, transport.id) if after_dibang else {}
|
||
|
||
header = header_model(
|
||
id=generate_uuid(),
|
||
order_no=order_no,
|
||
plan_id=plan.id,
|
||
transport_id=transport.id,
|
||
status="in_progress",
|
||
partner_company=partner,
|
||
vehicle_no=license_plate or None,
|
||
plan_no=(plan.plan_no or "").strip() or None,
|
||
plan_date=plan_date,
|
||
site_access_token=token,
|
||
create_by=user_id,
|
||
create_dept_id=dept_id or "",
|
||
tenant_no=tenant_no,
|
||
)
|
||
db.add(header)
|
||
|
||
for idx, product in enumerate(products):
|
||
plan_qty = Decimal(str(_product_quantity(product)))
|
||
actual = dibang_map.get(product.id)
|
||
qty_source = "dibang" if actual is not None else "plan"
|
||
if actual is None:
|
||
actual = plan_qty
|
||
db.add(
|
||
item_model(
|
||
id=generate_uuid(),
|
||
order_id=header.id,
|
||
plan_product_id=product.id,
|
||
product_id=product.product_id,
|
||
product_name=product.product_name,
|
||
specification=product.model,
|
||
unit=product.unit,
|
||
plan_qty=plan_qty,
|
||
actual_qty=actual,
|
||
qty_source=qty_source,
|
||
order_num=idx + 1,
|
||
create_by=user_id,
|
||
create_dept_id=dept_id or "",
|
||
tenant_no=tenant_no,
|
||
)
|
||
)
|
||
|
||
db.flush()
|
||
return IoOrderCreateResult(
|
||
kind=kind,
|
||
order_id=header.id,
|
||
order_no=order_no,
|
||
site_access_token=token,
|
||
site_url=build_io_order_site_url(kind, token),
|
||
)
|
||
|
||
|
||
def apply_dibang_prefill_to_order(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan: PlPlan,
|
||
transport: PlTransport,
|
||
) -> None:
|
||
"""地磅单生成后,用数量预填尚未被现场改过的明细 actual_qty。"""
|
||
if plan.plan_type not in ("inbound", "outbound"):
|
||
return
|
||
header_model = PlInboundOrder if plan.plan_type == "inbound" else PlOutboundOrder
|
||
item_model = PlInboundOrderItem if plan.plan_type == "inbound" else PlOutboundOrderItem
|
||
header = (
|
||
db.query(header_model)
|
||
.filter(
|
||
header_model.tenant_no == tenant_no,
|
||
header_model.plan_id == plan.id,
|
||
header_model.transport_id == transport.id,
|
||
header_model.status != "voided",
|
||
header_model.del_flag == 0,
|
||
)
|
||
.order_by(header_model.create_time.desc())
|
||
.first()
|
||
)
|
||
if not header:
|
||
return
|
||
dibang_map = _dibang_qty_by_plan_product(db, tenant_no, transport.id)
|
||
if not dibang_map:
|
||
return
|
||
items = (
|
||
db.query(item_model)
|
||
.filter(item_model.order_id == header.id, item_model.del_flag == 0)
|
||
.all()
|
||
)
|
||
for item in items:
|
||
qty = dibang_map.get(item.plan_product_id)
|
||
if qty is None:
|
||
continue
|
||
if item.qty_source == "manual":
|
||
continue
|
||
item.actual_qty = qty
|
||
item.qty_source = "dibang"
|
||
db.flush()
|
||
|
||
|
||
def void_io_orders_for_plan(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
plan_id: str,
|
||
user_id: Optional[str],
|
||
) -> None:
|
||
for model in (PlInboundOrder, PlOutboundOrder):
|
||
rows = (
|
||
db.query(model)
|
||
.filter(
|
||
model.tenant_no == tenant_no,
|
||
model.plan_id == plan_id,
|
||
model.status != "voided",
|
||
model.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
row.status = "voided"
|
||
row.update_by = user_id
|
||
db.flush()
|
||
|
||
|
||
def get_active_io_order_for_plan(
|
||
db: Session, tenant_no: str, plan: PlPlan
|
||
) -> Optional[tuple[str, OrderHeader]]:
|
||
if plan.plan_type == "inbound":
|
||
row = (
|
||
db.query(PlInboundOrder)
|
||
.filter(
|
||
PlInboundOrder.tenant_no == tenant_no,
|
||
PlInboundOrder.plan_id == plan.id,
|
||
PlInboundOrder.del_flag == 0,
|
||
)
|
||
.order_by(PlInboundOrder.create_time.desc())
|
||
.first()
|
||
)
|
||
return ("inbound", row) if row else None
|
||
if plan.plan_type == "outbound":
|
||
row = (
|
||
db.query(PlOutboundOrder)
|
||
.filter(
|
||
PlOutboundOrder.tenant_no == tenant_no,
|
||
PlOutboundOrder.plan_id == plan.id,
|
||
PlOutboundOrder.del_flag == 0,
|
||
)
|
||
.order_by(PlOutboundOrder.create_time.desc())
|
||
.first()
|
||
)
|
||
return ("outbound", row) if row else None
|
||
return None
|
||
|
||
|
||
def parse_feedback_images(raw: Optional[str]) -> list[str]:
|
||
if not raw:
|
||
return []
|
||
try:
|
||
parsed = json.loads(raw)
|
||
if isinstance(parsed, list):
|
||
return [str(item) for item in parsed if item]
|
||
except (TypeError, json.JSONDecodeError):
|
||
pass
|
||
return [part.strip() for part in raw.split(",") if part.strip()]
|
||
|
||
|
||
def dump_feedback_images(images: list[str]) -> str:
|
||
return json.dumps(images, ensure_ascii=False)
|
||
|
||
|
||
_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_mode 空导致只删不写)
|
||
missing_ids = [pid for pid in plan_product_ids if pid and pid not in products_by_id]
|
||
if missing_ids:
|
||
stale_rows = (
|
||
db.query(PlPlanProduct)
|
||
.filter(
|
||
PlPlanProduct.id.in_(missing_ids),
|
||
PlPlanProduct.tenant_no == tenant_no,
|
||
)
|
||
.all()
|
||
)
|
||
for row in stale_rows:
|
||
products_by_id.setdefault(row.id, row)
|
||
|
||
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]
|
||
# 客户端带了品牌明细时,即使计划行 brand_mode 丢失也按 any 规则校验/落库
|
||
if not brand_mode and rows:
|
||
brand_mode = "any"
|
||
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":
|
||
raise ValueError("单据已作废,无法提交")
|
||
if status not in _VALID_SITE_FEEDBACK_STATUSES:
|
||
raise ValueError(f"无效状态: {status}")
|
||
now = datetime.now()
|
||
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
|
||
if not header.site_first_submitted_at:
|
||
header.site_first_submitted_at = now
|
||
header.status = status
|
||
header.update_by = user_id
|
||
db.flush()
|
||
|
||
|
||
CRM_PRODUCT_UNIT = "crm_product_unit"
|
||
|
||
PLAN_TYPE_BUSINESS_LABEL = {
|
||
"inbound": "入库",
|
||
"outbound": "出库",
|
||
"direct": "直销",
|
||
}
|
||
|
||
|
||
def resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str:
|
||
code = (unit_code or "").strip()
|
||
if not code:
|
||
return "吨"
|
||
return unit_map.get(code, code)
|
||
|
||
|
||
def merge_plan_logistics_into_order(data: dict, plan: Optional[PlPlan]) -> None:
|
||
if not plan:
|
||
data.setdefault("businessType", None)
|
||
data.setdefault("planRemark", None)
|
||
data.setdefault("pickupAddress", None)
|
||
data.setdefault("pickupLng", None)
|
||
data.setdefault("pickupLat", None)
|
||
data.setdefault("pickupTime", None)
|
||
data.setdefault("pickupNotes", None)
|
||
data.setdefault("destinationName", None)
|
||
data.setdefault("destinationAddress", None)
|
||
data.setdefault("destinationLng", None)
|
||
data.setdefault("destinationLat", None)
|
||
data.setdefault("deliveryTime", None)
|
||
data.setdefault("destinationNotes", None)
|
||
return
|
||
data["businessType"] = PLAN_TYPE_BUSINESS_LABEL.get(plan.plan_type, plan.plan_type)
|
||
data["planRemark"] = (plan.remark or "").strip() or None
|
||
data["pickupAddress"] = plan.pickup_address
|
||
data["pickupLng"] = plan.pickup_lng
|
||
data["pickupLat"] = plan.pickup_lat
|
||
data["pickupTime"] = plan.pickup_time
|
||
data["pickupNotes"] = plan.pickup_notes
|
||
data["destinationName"] = plan.destination_name
|
||
data["destinationAddress"] = plan.destination_address or plan.destination_name
|
||
data["destinationLng"] = plan.destination_lng
|
||
data["destinationLat"] = plan.destination_lat
|
||
data["deliveryTime"] = plan.delivery_time
|
||
data["destinationNotes"] = plan.destination_notes
|
||
|
||
|
||
def merge_plan_product_into_item(
|
||
item: dict,
|
||
product: Optional[PlPlanProduct],
|
||
*,
|
||
unit_map: dict[str, str],
|
||
crm_product_no: Optional[str] = None,
|
||
) -> None:
|
||
if not product:
|
||
item.setdefault("productNo", None)
|
||
item.setdefault("categoryName", None)
|
||
item.setdefault("packageSpec", None)
|
||
item.setdefault("deliveryMethod", None)
|
||
item.setdefault("remark", None)
|
||
item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit"))
|
||
return
|
||
# 优先 CRM 产品编码;计划冗余 product_no 可能被误存为 product_id
|
||
product_id = (product.product_id or item.get("productId") or item.get("product_id") or "").strip()
|
||
plan_no = (product.product_no or "").strip()
|
||
resolved_no = (crm_product_no or "").strip()
|
||
if not resolved_no and plan_no and plan_no != product_id:
|
||
resolved_no = plan_no
|
||
# CRM 缺失时仍回退计划 product_no,避免前端编码列空白
|
||
if not resolved_no and plan_no:
|
||
resolved_no = plan_no
|
||
item["productNo"] = resolved_no or None
|
||
item["categoryName"] = product.category_name
|
||
item["packageSpec"] = product.package_spec or item.get("packageSpec")
|
||
item["deliveryMethod"] = product.delivery_method
|
||
item["remark"] = (product.remark or "").strip() or item.get("remark")
|
||
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
|
||
if product.brand_mode:
|
||
item.setdefault("brandMode", product.brand_mode)
|
||
|
||
|
||
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)
|
||
plan_product_id = (it.get("planProductId") or it.get("plan_product_id") or "").strip()
|
||
pp = products_by_id.get(plan_product_id)
|
||
product_id = (
|
||
(pp.product_id if pp else None) or it.get("productId") or it.get("product_id") 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:
|
||
plan_product_id = (it.get("planProductId") or it.get("plan_product_id") or "").strip()
|
||
pp = products_by_id.get(plan_product_id)
|
||
product_id = (
|
||
(pp.product_id if pp else None) or it.get("productId") or it.get("product_id") 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(
|
||
db: Session,
|
||
data: dict,
|
||
*,
|
||
tenant_no: str,
|
||
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
|
||
|
||
plan = None
|
||
if plan_id:
|
||
plan = (
|
||
db.query(PlPlan)
|
||
.filter(
|
||
PlPlan.id == plan_id,
|
||
PlPlan.tenant_no == tenant_no,
|
||
PlPlan.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
merge_plan_logistics_into_order(data, plan)
|
||
|
||
unit_map = load_dict_label_maps(
|
||
db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no
|
||
).get(CRM_PRODUCT_UNIT, {})
|
||
|
||
raw_items = data.get("items")
|
||
if raw_items is None:
|
||
return data
|
||
|
||
product_ids = [
|
||
(it.get("planProductId") or it.get("plan_product_id") or "").strip()
|
||
for it in raw_items
|
||
if (it.get("planProductId") or it.get("plan_product_id") or "").strip()
|
||
]
|
||
products_by_id: dict[str, PlPlanProduct] = {}
|
||
if product_ids:
|
||
rows = (
|
||
db.query(PlPlanProduct)
|
||
.filter(
|
||
PlPlanProduct.id.in_(product_ids),
|
||
PlPlanProduct.tenant_no == tenant_no,
|
||
PlPlanProduct.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
products_by_id = {r.id: r for r in rows}
|
||
# 明细仍挂已软删计划产品时,保留原行用于品牌约定与 CRM 产品定位(勿误用其它在用产品)
|
||
missing_ids = [pid for pid in product_ids if pid and pid not in products_by_id]
|
||
if missing_ids:
|
||
stale_rows = (
|
||
db.query(PlPlanProduct)
|
||
.filter(
|
||
PlPlanProduct.id.in_(missing_ids),
|
||
PlPlanProduct.tenant_no == tenant_no,
|
||
)
|
||
.all()
|
||
)
|
||
for row in stale_rows:
|
||
products_by_id.setdefault(row.id, row)
|
||
|
||
# 计划产品行可能被编辑重建导致 plan_product_id 失效:按计划再拉全量做 product_id / 序号兜底
|
||
plan_products: list[PlPlanProduct] = []
|
||
products_by_crm_id: dict[str, PlPlanProduct] = {}
|
||
if plan_id:
|
||
plan_products = (
|
||
db.query(PlPlanProduct)
|
||
.filter(
|
||
PlPlanProduct.plan_id == plan_id,
|
||
PlPlanProduct.tenant_no == tenant_no,
|
||
PlPlanProduct.del_flag == 0,
|
||
)
|
||
.order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
|
||
.all()
|
||
)
|
||
for row in plan_products:
|
||
products_by_id.setdefault(row.id, row)
|
||
crm_key = (row.product_id or "").strip()
|
||
if crm_key and crm_key not in products_by_crm_id:
|
||
products_by_crm_id[crm_key] = row
|
||
|
||
crm_ids = {
|
||
(pp.product_id or "").strip()
|
||
for pp in products_by_id.values()
|
||
if (pp.product_id or "").strip()
|
||
}
|
||
for it in raw_items:
|
||
pid = (it.get("productId") or it.get("product_id") or "").strip()
|
||
if pid:
|
||
crm_ids.add(pid)
|
||
crm_no_by_id: dict[str, str] = {}
|
||
if crm_ids:
|
||
crm_rows = (
|
||
db.query(CrmProduct.id, CrmProduct.product_no)
|
||
.filter(
|
||
CrmProduct.id.in_(list(crm_ids)),
|
||
CrmProduct.tenant_no == tenant_no,
|
||
CrmProduct.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
for row in crm_rows:
|
||
no = (row.product_no or "").strip()
|
||
if no:
|
||
crm_no_by_id[row.id] = no
|
||
|
||
standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
|
||
db, tenant_no=tenant_no, product_ids=crm_ids
|
||
)
|
||
|
||
for idx, it in enumerate(raw_items):
|
||
plan_product_id = (it.get("planProductId") or it.get("plan_product_id") or "").strip()
|
||
if plan_product_id and not it.get("planProductId"):
|
||
it["planProductId"] = plan_product_id
|
||
pp = products_by_id.get(plan_product_id) if plan_product_id else None
|
||
# 仅在明细未挂 planProductId 时用 CRM/序号兜底;已挂 id 时勿换成其它在用产品(会错带指定品牌)
|
||
if not pp and not plan_product_id:
|
||
crm_key = (it.get("productId") or it.get("product_id") or "").strip()
|
||
if crm_key:
|
||
pp = products_by_crm_id.get(crm_key)
|
||
if not pp and len(plan_products) == len(raw_items):
|
||
pp = plan_products[idx]
|
||
if pp and not it.get("planProductId"):
|
||
it["planProductId"] = pp.id
|
||
product_id = (
|
||
(pp.product_id if pp else None) or it.get("productId") or it.get("product_id") or ""
|
||
).strip()
|
||
# 优先用计划产品已存名(可能是别名)做拆分;明细行 productName 同步
|
||
saved_name = (pp.product_name if pp else None) or it.get("productName") or it.get(
|
||
"product_name"
|
||
)
|
||
if saved_name:
|
||
it["productName"] = saved_name
|
||
merge_plan_product_into_item(
|
||
it,
|
||
pp,
|
||
unit_map=unit_map,
|
||
crm_product_no=crm_no_by_id.get(product_id),
|
||
)
|
||
apply_product_alias_display(
|
||
it,
|
||
product_id=product_id,
|
||
standard_by_id=standard_by_id,
|
||
alias_options_by_id=alias_options_by_id,
|
||
)
|
||
if public_unit_as_label:
|
||
it["unit"] = it.get("unitLabel") or resolve_unit_label(unit_map, it.get("unit"))
|
||
|
||
brand_summary_by_pp: dict[str, str] = {}
|
||
if product_ids:
|
||
brand_rows = (
|
||
db.query(PlPlanProductBrandQty)
|
||
.filter(
|
||
PlPlanProductBrandQty.plan_product_id.in_(product_ids),
|
||
PlPlanProductBrandQty.tenant_no == tenant_no,
|
||
PlPlanProductBrandQty.del_flag == 0,
|
||
)
|
||
.order_by(PlPlanProductBrandQty.order_num.asc())
|
||
.all()
|
||
)
|
||
grouped: dict[str, list[str]] = {}
|
||
for br in brand_rows:
|
||
qty = Decimal(str(br.qty or 0))
|
||
if qty <= 0:
|
||
continue
|
||
name = (br.brand_name or "").strip() or "未命名品牌"
|
||
# 去掉多余尾零,如 6.000 -> 6
|
||
qty_text = format(qty.normalize(), "f")
|
||
grouped.setdefault(br.plan_product_id, []).append(f"{name} {qty_text}")
|
||
brand_summary_by_pp = {k: " / ".join(v) for k, v in grouped.items()}
|
||
|
||
for it in raw_items:
|
||
pp_id = it.get("planProductId") or ""
|
||
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
|
||
|
||
|
||
def order_header_to_dict(
|
||
kind: str,
|
||
row: OrderHeader,
|
||
items: list[Any] | None = None,
|
||
*,
|
||
db: Session | None = None,
|
||
public_unit_as_label: bool = False,
|
||
) -> dict:
|
||
token = row.site_access_token or ""
|
||
data = {
|
||
"id": row.id,
|
||
"orderNo": row.order_no,
|
||
"planId": row.plan_id,
|
||
"transportId": row.transport_id,
|
||
"status": row.status,
|
||
"partnerCompany": row.partner_company,
|
||
"vehicleNo": row.vehicle_no,
|
||
"planNo": row.plan_no,
|
||
"planDate": row.plan_date.isoformat() if row.plan_date else None,
|
||
"siteAccessToken": token,
|
||
"siteUrl": build_io_order_site_url(kind, token) if token else None,
|
||
"siteFeedbackRemark": row.site_feedback_remark,
|
||
"siteFeedbackImages": parse_feedback_images(row.site_feedback_images),
|
||
"siteFeedbackTime": row.site_feedback_time,
|
||
"siteFirstSubmittedAt": row.site_first_submitted_at,
|
||
"remark": row.remark,
|
||
"tenantNo": row.tenant_no,
|
||
"createTime": row.create_time,
|
||
"updateTime": row.update_time,
|
||
"kind": kind,
|
||
}
|
||
if items is not None:
|
||
data["items"] = [
|
||
{
|
||
"id": it.id,
|
||
"orderId": it.order_id,
|
||
"planProductId": it.plan_product_id,
|
||
"productId": it.product_id,
|
||
"productName": it.product_name,
|
||
"specification": it.specification,
|
||
"unit": it.unit,
|
||
"planQty": it.plan_qty,
|
||
"actualQty": it.actual_qty,
|
||
"qtySource": it.qty_source,
|
||
}
|
||
for it in items
|
||
]
|
||
data["productSummary"] = "、".join(
|
||
(it.product_name or "").strip() for it in items if (it.product_name or "").strip()
|
||
)[:200]
|
||
data["actualQtyTotal"] = float(sum(Decimal(str(it.actual_qty or 0)) for it in items))
|
||
if db is not None:
|
||
enrich_io_order_dict(
|
||
db,
|
||
data,
|
||
tenant_no=row.tenant_no,
|
||
plan_id=row.plan_id,
|
||
items=items,
|
||
public_unit_as_label=public_unit_as_label,
|
||
kind=kind,
|
||
)
|
||
return data
|