| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927 |
- """运输计划 · 入库单 / 出库单(安排运输落单、现场回传、作废)"""
- 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
|