feat(pl): 出入库单读时 enrichment 取送货与产品扩展字段

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-19 16:39:20 +08:00
1 parent dbd0d9f205
commit 4f69a9b904
2 files changed
+584

No files matched your search

+519
View File
@@ -0,0 +1,519 @@
"""运输计划 · 入库单 / 出库单(安排运输落单、现场回传、作废)"""
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.db_models import DbInboundOutbound
from app.models.pl_models import (
PlInboundOrder,
PlInboundOrderItem,
PlOutboundOrder,
PlOutboundOrderItem,
PlPlan,
PlPlanProduct,
PlTransport,
PlTransportDbLink,
)
from app.services.db.mappers import parse_date_str
from app.services.pl_dibang_bridge_service import _product_quantity, resolve_partner_company
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)
def apply_site_feedback(
db: Session,
*,
header: OrderHeader,
items: list[OrderItem],
item_qty: dict[str, Decimal],
remark: Optional[str],
images: list[str],
user_id: Optional[str] = None,
) -> None:
if header.status == "voided":
raise ValueError("单据已作废,无法提交")
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
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
if header.status == "in_progress":
header.status = "completed"
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],
) -> None:
if not product:
item.setdefault("productNo", None)
item.setdefault("packageSpec", None)
item.setdefault("remark", None)
item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit"))
return
item["productNo"] = product.product_no
item["packageSpec"] = product.package_spec
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)
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,
) -> 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.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")
for it in raw_items
if it.get("planProductId")
]
products_by_id: dict[str, PlPlanProduct] = {}
if product_ids:
rows = (
db.query(PlPlanProduct)
.filter(
PlPlanProduct.id.in_(product_ids),
PlPlanProduct.del_flag == 0,
)
.all()
)
products_by_id = {r.id: r for r in rows}
for it in raw_items:
pp = products_by_id.get(it.get("planProductId") or "")
merge_plan_product_into_item(it, pp, unit_map=unit_map)
if public_unit_as_label:
it["unit"] = it.get("unitLabel") or resolve_unit_label(unit_map, it.get("unit"))
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,
)
return data
@@ -0,0 +1,65 @@
from app.services.pl_io_order_service import (
resolve_unit_label,
PLAN_TYPE_BUSINESS_LABEL,
merge_plan_logistics_into_order,
merge_plan_product_into_item,
)
def test_resolve_unit_label_maps_dun():
assert resolve_unit_label({"dun": "吨"}, "dun") == "吨"
assert resolve_unit_label({}, "dun") == "dun"
assert resolve_unit_label({}, None) == "吨"
assert resolve_unit_label({}, "") == "吨"
def test_merge_plan_logistics_into_order():
data = {"remark": "order-remark"}
plan = type(
"P",
(),
{
"plan_type": "outbound",
"remark": "plan-remark",
"pickup_address": "取货路1号",
"pickup_lng": 117.0,
"pickup_lat": 30.5,
"pickup_time": None,
"pickup_notes": "戴帽",
"destination_name": "仓库A",
"destination_address": "送货路2号",
"destination_lng": None,
"destination_lat": None,
"delivery_time": None,
"destination_notes": "北门",
},
)()
merge_plan_logistics_into_order(data, plan)
assert data["businessType"] == "出库"
assert data["planRemark"] == "plan-remark"
assert data["remark"] == "order-remark" # 单据备注不被覆盖
assert data["pickupAddress"] == "取货路1号"
assert data["destinationAddress"] == "送货路2号"
assert data["destinationName"] == "仓库A"
def test_merge_plan_product_into_item():
item = {"unit": "dun", "productName": "尿素"}
product = type(
"PP",
(),
{
"product_no": "P001",
"package_spec": "50kg/袋",
"remark": "易潮",
"unit": "dun",
"model": "46.4%",
"product_name": "工业级尿素",
},
)()
merge_plan_product_into_item(item, product, unit_map={"dun": "吨"})
assert item["productNo"] == "P001"
assert item["packageSpec"] == "50kg/袋"
assert item["remark"] == "易潮"
assert item["unitLabel"] == "吨"
assert item["unit"] == "dun"