Files
shanxia2.0/backend/app/services/pl_dibang_bridge_service.py
T

1811 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""运输计划安排时联动地磅计划与出入库单。"""
from __future__ import annotations
import calendar
import re
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Literal, Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.crm_models import CrmProduct
from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
from app.models.pl_models import (
PlPlan,
PlPlanDelivery,
PlPlanDeliveryProduct,
PlPlanLoad,
PlPlanLoadProduct,
PlPlanProduct,
PlTransport,
PlTransportDbLink,
)
from app.services.db.inventory_id import next_inbound_outbound_id
from app.services.db.mappers import parse_date_str
from app.services.db.plan_helpers import default_execution_status, generate_plan_no
from app.services.db.product_helpers import (
apply_product_snapshot_to_inventory,
apply_product_snapshot_to_plan,
resolve_crm_product,
)
from app.services.pl_plan_stop_rules import normalize_stop_dibang_fields
@dataclass
class DibangInventoryLinkResult:
plan_product_id: str
product_name: str
db_purchase_plan_id: str
db_inventory_id: str
batch_no: str
plan_no: str
@dataclass
class StopDibangOption:
stop_type: Literal["load", "delivery"]
stop_id: str
generate_dibang: bool
@dataclass
class _StopDibangProduct:
plan_product_id: str
product_id: str
product_name: str
model: Optional[str]
unit: str
quantity: float
def resolve_partner_company(plan: PlPlan, plan_type: str) -> str:
"""按运输计划单据类型解析地磅往来单位(不区分 CRM 合同类型)。
- 入库单 inbound:供方 → party_a,其次 supplier_name
- 出库单 outbound:需方 → party_b,其次 customer_name
"""
if plan_type == "inbound":
return (plan.party_a or plan.supplier_name or "").strip()
if plan_type == "outbound":
return (plan.party_b or plan.customer_name or "").strip()
return ""
def _normalize_to_date(value: date | datetime | str | None) -> Optional[date]:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, str):
return parse_date_str(value)
return None
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, date):
return plan_date
if isinstance(plan_date, str):
parsed = parse_date_str(plan_date)
return parsed or date.today()
return date.today()
def _resolve_dibang_date(plan: PlPlan) -> date:
"""入库单用送达时间,出库单用取货时间;缺失时回退计划日期。"""
if plan.plan_type == "inbound":
resolved = _normalize_to_date(plan.delivery_time)
elif plan.plan_type == "outbound":
resolved = _normalize_to_date(plan.pickup_time)
else:
resolved = None
return resolved or _normalize_plan_date(plan)
def _month_range(plan_date: date) -> tuple[date, date]:
month_start = plan_date.replace(day=1)
last_day = calendar.monthrange(plan_date.year, plan_date.month)[1]
month_end = plan_date.replace(day=last_day)
return month_start, month_end
def _plan_type_to_business_type(plan_type: str) -> Optional[str]:
if plan_type == "inbound":
return "入库"
if plan_type == "outbound":
return "出库"
return None
def _direction_to_business_type(direction: Optional[str]) -> Optional[str]:
normalized = (direction or "").strip()
if normalized == "outbound":
return "出库"
if normalized == "inbound":
return "入库"
return None
def _resolve_stop_business_type(
stop: PlPlanLoad | PlPlanDelivery,
stop_type: Literal["load", "delivery"],
) -> Optional[str]:
_, direction = normalize_stop_dibang_fields(
stop.customer_name,
stop_type,
stop.generate_dibang,
stop.dibang_direction,
)
return _direction_to_business_type(direction)
def resolve_stop_dibang_partner(
stop_type: Literal["load", "delivery"],
load: Optional[PlPlanLoad],
deliveries: list[PlPlanDelivery],
) -> str:
"""装货段地磅往来单位取第一个送货客户;送货段取装货客户。"""
if stop_type == "load":
if deliveries:
return (deliveries[0].customer_name or "").strip()
return (load.customer_name or "").strip() if load else ""
return (load.customer_name or "").strip() if load else ""
def _stop_product_quantity(product: Any) -> float:
if getattr(product, "plan_qty", None) is not None:
return float(product.plan_qty)
return 0.0
def plan_has_load_stop(db: Session, tenant_no: str, plan_id: str) -> bool:
return (
db.query(PlPlanLoad.id)
.filter(
PlPlanLoad.plan_id == plan_id,
PlPlanLoad.tenant_no == tenant_no,
PlPlanLoad.del_flag == 0,
)
.first()
is not None
)
def apply_stop_dibang_options(
db: Session,
*,
tenant_no: str,
plan_id: str,
stop_options: list[StopDibangOption],
user_id: Optional[str],
) -> None:
for option in stop_options:
if option.stop_type == "load":
stop = (
db.query(PlPlanLoad)
.filter(
PlPlanLoad.id == option.stop_id,
PlPlanLoad.plan_id == plan_id,
PlPlanLoad.tenant_no == tenant_no,
PlPlanLoad.del_flag == 0,
)
.first()
)
else:
stop = (
db.query(PlPlanDelivery)
.filter(
PlPlanDelivery.id == option.stop_id,
PlPlanDelivery.plan_id == plan_id,
PlPlanDelivery.tenant_no == tenant_no,
PlPlanDelivery.del_flag == 0,
)
.first()
)
if stop:
gen, direction = normalize_stop_dibang_fields(
stop.customer_name,
"load" if option.stop_type == "load" else "delivery",
1 if option.generate_dibang else 0,
stop.dibang_direction,
)
stop.generate_dibang = gen
stop.dibang_direction = direction
stop.update_by = user_id
db.flush()
def _product_quantity(product: PlPlanProduct) -> float:
for value in (product.quantity, product.inbound_estimate_qty, product.outbound_estimate_qty):
if value is not None:
return float(value)
return 0.0
def _apply_plan_quantity_delta(
db_plan: DbPurchasePlan,
delta: float,
*,
user_id: Optional[str],
) -> None:
current = float(db_plan.planned_quantity or 0)
db_plan.planned_quantity = max(0.0, current + float(delta))
db_plan.update_by = user_id
def _resolve_link_contributed_qty(
link: PlTransportDbLink,
inventory: Optional[DbInboundOutbound],
) -> float:
if link.contributed_qty is not None:
return float(link.contributed_qty)
if inventory and inventory.quantity is not None:
return float(inventory.quantity)
return 0.0
def _subtract_plan_contribution(
db: Session,
*,
tenant_no: str,
link: PlTransportDbLink,
inventory: Optional[DbInboundOutbound],
user_id: Optional[str],
current_read: bool = False,
) -> None:
if not link.db_purchase_plan_id:
return
query = (
db.query(DbPurchasePlan)
.filter(
DbPurchasePlan.id == link.db_purchase_plan_id,
DbPurchasePlan.tenant_no == tenant_no,
DbPurchasePlan.del_flag == 0,
)
)
db_plan = (query.with_for_update() if current_read else query).first()
if not db_plan:
return
qty = _resolve_link_contributed_qty(link, inventory)
if qty:
_apply_plan_quantity_delta(db_plan, -qty, user_id=user_id)
def _generate_batch_no(db: Session, tenant_no: str) -> str:
rows = (
db.query(DbInboundOutbound.batch_no)
.filter(
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
DbInboundOutbound.batch_no.like("CRKDH%"),
)
.all()
)
max_seq = 0
pattern = re.compile(r"^CRKDH(\d+)$")
for (batch_no,) in rows:
if batch_no:
match = pattern.match(batch_no)
if match:
max_seq = max(max_seq, int(match.group(1)))
return f"CRKDH{max_seq + 1:07d}"
def find_monthly_db_plan(
db: Session,
tenant_no: str,
plan_date: date,
business_type: str,
customer_name: str,
product_id: str,
specification: Optional[str],
) -> Optional[DbPurchasePlan]:
month_start, month_end = _month_range(plan_date)
customer = (customer_name or "").strip()
spec = (specification or "").strip()
query = db.query(DbPurchasePlan).filter(
DbPurchasePlan.tenant_no == tenant_no,
DbPurchasePlan.del_flag == 0,
DbPurchasePlan.business_type == business_type,
DbPurchasePlan.product_id == product_id,
DbPurchasePlan.start_date <= month_end,
DbPurchasePlan.end_date >= month_start,
)
if customer:
query = query.filter(DbPurchasePlan.customer_name == customer)
if spec:
query = query.filter(DbPurchasePlan.specification == spec)
return query.order_by(DbPurchasePlan.create_time.desc()).first()
def _find_stop_monthly_plan(db: Session, tenant_no: str, plan_date: date, business_type: str,
customer_name: str, product_id: str, specification: Optional[str]):
"""锁定读保证等待共享产品锁后可见最新月计划,不从历史重复行中猜配。"""
start, end = _month_range(plan_date)
rows = db.query(DbPurchasePlan).filter(
DbPurchasePlan.tenant_no == tenant_no, DbPurchasePlan.del_flag == 0,
DbPurchasePlan.business_type == business_type, DbPurchasePlan.product_id == product_id,
func.trim(func.coalesce(DbPurchasePlan.customer_name, "")) == (customer_name or "").strip(),
func.trim(func.coalesce(DbPurchasePlan.specification, "")) == (specification or "").strip(),
DbPurchasePlan.start_date <= end, DbPurchasePlan.end_date >= start,
).with_for_update().limit(2).all()
if len(rows) > 1:
raise ValueError("存在重复地磅月计划,请先核对历史关联")
return rows[0] if rows else None
def _create_db_plan(
db: Session,
tenant_no: str,
plan: PlPlan,
product: PlPlanProduct,
business_type: str,
customer_name: str,
dibang_date: date,
month_start: date,
month_end: date,
user_id: Optional[str],
dept_id: str,
contributed_qty: float,
) -> DbPurchasePlan:
snapshot = resolve_crm_product(db, tenant_no, product.product_id, require_on_shelf=False)
db_plan = DbPurchasePlan(
id=generate_uuid(),
tenant_no=tenant_no,
plan_no=generate_plan_no(),
plan_date=dibang_date,
start_date=month_start,
end_date=month_end,
business_type=business_type,
planned_quantity=contributed_qty,
vehicle_no=(plan.license_plate or "").strip(),
customer_name=customer_name or None,
remark=f"来源运输计划 {plan.plan_no}",
execution_status=default_execution_status(business_type),
executed_at=None,
last_weighing_slip_id=None,
executed_quantity=0,
auto_generate_enabled=0,
auto_generate_end_month=None,
auto_generate_last_month=None,
create_by=user_id,
create_dept_id=dept_id,
)
apply_product_snapshot_to_plan(db_plan, snapshot)
db.add(db_plan)
db.flush()
return db_plan
def _create_db_inventory(
db: Session,
tenant_no: str,
plan: PlPlan,
product: PlPlanProduct,
db_plan: DbPurchasePlan,
business_type: str,
customer_name: str,
license_plate: str,
dibang_date: date,
user_id: Optional[str],
dept_id: str,
) -> DbInboundOutbound:
snapshot = resolve_crm_product(db, tenant_no, product.product_id, require_on_shelf=False)
status_value = "待入库" if business_type == "入库" else "待出库"
inventory_plan_no = ((db_plan.plan_no if db_plan else plan.plan_no) or "").strip() or None
remark = f"运输计划 {plan.plan_no}"
purchase_plan_id = db_plan.id if db_plan else None
inventory = DbInboundOutbound(
id=next_inbound_outbound_id(db, tenant_no),
tenant_no=tenant_no,
date=dibang_date,
partner_company=customer_name,
business_type=business_type,
quantity=Decimal(str(_product_quantity(product))),
plan_no=inventory_plan_no,
batch_no=_generate_batch_no(db, tenant_no),
vehicle_no=(license_plate or "").strip(),
status=status_value,
remark=remark,
purchase_plan_id=purchase_plan_id,
create_by=user_id,
create_dept_id=dept_id,
)
apply_product_snapshot_to_inventory(inventory, snapshot)
db.add(inventory)
db.flush()
return inventory
# 仅这些状态的出入库单可在「再次安排+勾地磅」时软删后重建
_REVOKABLE_INVENTORY_STATUSES = frozenset({"待入库", "待出库", "待三方填写", "待处理"})
def _inventory_has_weighing_slip(db: Session, inventory_id: str, *,
tenant_no: Optional[str] = None, current_read: bool = False) -> bool:
query = db.query(DbWeighingSlip.id).filter(
DbWeighingSlip.inbound_outbound_id == inventory_id,
DbWeighingSlip.del_flag == 0, DbWeighingSlip.is_voided == 0,
)
if tenant_no is not None:
query = query.filter(DbWeighingSlip.tenant_no == tenant_no)
if current_read:
query = query.with_for_update()
return query.first() is not None
def _sync_weighing_slip_business_fields(
db: Session,
*,
inventory: DbInboundOutbound,
user_id: Optional[str],
) -> None:
"""按出入库正文回写关联过磅单业务字段;称重数字一律不动。"""
slips = (
db.query(DbWeighingSlip)
.filter(
DbWeighingSlip.inbound_outbound_id == inventory.id,
DbWeighingSlip.tenant_no == inventory.tenant_no,
DbWeighingSlip.del_flag == 0,
DbWeighingSlip.is_voided == 0,
)
.with_for_update().all()
)
if not slips:
return
partner = (inventory.partner_company or "").strip()
business_type = (inventory.business_type or "").strip()
for slip in slips:
plate = (inventory.vehicle_no or "").strip()
if plate:
slip.vehicle_no = plate
if inventory.product_id:
slip.product_id = inventory.product_id
if inventory.product:
slip.cargo_name = inventory.product
slip.specification = inventory.specification
if business_type:
slip.business_type = business_type
if inventory.plan_no:
slip.plan_no = inventory.plan_no
if inventory.batch_no:
slip.batch_no = inventory.batch_no
if inventory.purchase_plan_id:
slip.purchase_plan_id = inventory.purchase_plan_id
if partner:
if business_type == "入库":
slip.supplier = partner
else:
slip.consignee = partner
slip.update_by = user_id
def revoke_regeneratable_dibang_for_transport(
db: Session,
*,
tenant_no: str,
transport_id: str,
user_id: Optional[str],
) -> None:
"""再次勾选地磅安排前:软删本运输单此前生成的、仍可作废的出入库单及关联。
- 不删除地磅月计划(可能被其他业务复用)
- 若出入库单已完成的/已有关联磅单 → 阻断并提示人工处理
"""
links = (
db.query(PlTransportDbLink)
.filter(
PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.transport_id == transport_id,
PlTransportDbLink.del_flag == 0,
)
.all()
)
if not links:
return
blocking: list[str] = []
for link in links:
inventory = (
db.query(DbInboundOutbound)
.filter(
DbInboundOutbound.id == link.db_inventory_id,
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
)
.first()
)
if not inventory:
link.del_flag = 1
link.update_by = user_id
continue
label = inventory.batch_no or inventory.id
status = (inventory.status or "").strip()
if status and status not in _REVOKABLE_INVENTORY_STATUSES:
blocking.append(f"{label}(状态:{status})")
continue
if _inventory_has_weighing_slip(db, inventory.id):
blocking.append(f"{label}(已关联地磅单)")
if blocking:
raise ValueError(
"以下出入库单已执行或已关联地磅,无法随运输安排自动重建:"
+ ";".join(blocking)
+ "。请在地磅管理中处理后再安排。"
)
for link in links:
inventory = (
db.query(DbInboundOutbound)
.filter(
DbInboundOutbound.id == link.db_inventory_id,
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
)
.first()
)
if inventory:
_subtract_plan_contribution(
db,
tenant_no=tenant_no,
link=link,
inventory=inventory,
user_id=user_id,
)
inventory.del_flag = 1
inventory.update_by = user_id
link.del_flag = 1
link.update_by = user_id
db.flush()
def _inventory_date_editable(db: Session, inventory: DbInboundOutbound) -> bool:
status = (inventory.status or "").strip()
if status and status not in _REVOKABLE_INVENTORY_STATUSES:
return False
if _inventory_has_weighing_slip(db, inventory.id):
return False
return True
def _find_active_stop_link(
db: Session,
*,
tenant_no: str,
transport_id: str,
plan_id: str,
stop_type: str,
stop_id: str,
plan_product_id: str,
) -> Optional[PlTransportDbLink]:
rows = db.query(PlTransportDbLink).filter(
PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.transport_id == transport_id,
PlTransportDbLink.plan_id == plan_id,
PlTransportDbLink.stop_type == stop_type,
PlTransportDbLink.stop_id == stop_id,
PlTransportDbLink.plan_product_id == plan_product_id,
PlTransportDbLink.del_flag == 0,
).with_for_update().all()
if len(rows) > 1:
raise ValueError("段产品存在重复地磅关联,请先核对历史数据")
return rows[0] if rows else None
def _soft_delete_link_and_inventory(
db: Session,
*,
tenant_no: str,
link: PlTransportDbLink,
user_id: Optional[str],
) -> None:
inventory = (
db.query(DbInboundOutbound)
.filter(
DbInboundOutbound.id == link.db_inventory_id,
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
)
.with_for_update().first()
)
if inventory:
_subtract_plan_contribution(
db,
tenant_no=tenant_no,
link=link,
inventory=inventory,
user_id=user_id, current_read=True,
)
inventory.del_flag = 1
inventory.update_by = user_id
link.del_flag = 1
link.update_by = user_id
def _cancel_stop_dibang_links(
db: Session,
*,
tenant_no: str,
transport_id: str,
plan_id: str,
stop_type: str,
stop_id: str,
user_id: Optional[str],
) -> None:
"""取消段 generate_dibang:已过磅阻断;否则软删 link+出入库并回减月计划。"""
links = (
db.query(PlTransportDbLink)
.filter(
PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.transport_id == transport_id,
PlTransportDbLink.plan_id == plan_id,
PlTransportDbLink.stop_type == stop_type,
PlTransportDbLink.stop_id == stop_id,
PlTransportDbLink.del_flag == 0,
)
.with_for_update().all()
)
if not links:
return
blocking: list[str] = []
for link in links:
inventory = (
db.query(DbInboundOutbound)
.filter(
DbInboundOutbound.id == link.db_inventory_id,
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
)
.with_for_update().first()
)
if not inventory:
continue
label = inventory.batch_no or inventory.id
status = (inventory.status or "").strip()
if status and status not in _REVOKABLE_INVENTORY_STATUSES:
blocking.append(f"{label}(状态:{status})")
continue
if _inventory_has_weighing_slip(db, inventory.id, tenant_no=tenant_no, current_read=True):
blocking.append(label)
if blocking:
raise ValueError(
"以下出入库单已执行或已关联地磅,无法取消生成地磅:"
+ ";".join(blocking)
+ "。请在地磅管理中处理后再操作。"
)
for link in links:
_soft_delete_link_and_inventory(
db,
tenant_no=tenant_no,
link=link,
user_id=user_id,
)
db.flush()
def _update_existing_stop_inventory(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
link: PlTransportDbLink,
inventory: DbInboundOutbound,
product: _StopDibangProduct,
partner: str,
license_plate: str,
dibang_date: date,
user_id: Optional[str],
dept_id: str,
warnings: list[str],
) -> DibangInventoryLinkResult:
"""就地强制同步出入库正文(含已过磅),并回写关联过磅单业务字段。"""
label = inventory.batch_no or inventory.id
old_partner = (inventory.partner_company or "").strip()
old_product_id = (inventory.product_id or "").strip()
old_spec = (inventory.specification or "").strip()
old_plate = (inventory.vehicle_no or "").strip()
old_date = _normalize_to_date(inventory.date)
# —— 强制更新 partner / vehicle / 产品快照 / quantity ——
if partner and old_partner != partner:
inventory.partner_company = partner
plate = (license_plate or "").strip()
plate_changed = bool(plate and old_plate != plate)
if plate_changed:
inventory.vehicle_no = plate
incoming_product_id = (product.product_id or "").strip()
if incoming_product_id:
snapshot = resolve_crm_product(
db, tenant_no, incoming_product_id, require_on_shelf=False
)
apply_product_snapshot_to_inventory(inventory, snapshot)
# 数量差量先作用于当前挂靠月计划(改挂前);改挂时会整额扣回旧计划
new_qty = float(product.quantity)
old_qty = _resolve_link_contributed_qty(link, inventory)
if abs(new_qty - old_qty) > 1e-9:
delta = new_qty - old_qty
if link.db_purchase_plan_id:
db_plan = (
db.query(DbPurchasePlan)
.filter(
DbPurchasePlan.id == link.db_purchase_plan_id,
DbPurchasePlan.tenant_no == tenant_no,
DbPurchasePlan.del_flag == 0,
)
.with_for_update().first()
)
if db_plan:
_apply_plan_quantity_delta(db_plan, delta, user_id=user_id)
inventory.quantity = Decimal(str(new_qty))
link.contributed_qty = Decimal(str(new_qty))
new_partner = (inventory.partner_company or "").strip()
new_product_id = (inventory.product_id or "").strip()
new_spec = (inventory.specification or "").strip()
need_remount = (
(partner and old_partner != new_partner)
or (old_product_id and new_product_id and old_product_id != new_product_id)
or (old_spec != new_spec and bool(new_product_id or old_product_id))
)
remounted = False
if need_remount:
qty = _resolve_link_contributed_qty(link, inventory)
_subtract_plan_contribution(
db,
tenant_no=tenant_no,
link=link,
inventory=inventory,
user_id=user_id, current_read=True,
)
try:
new_db_plan = _ensure_monthly_db_plan_for_inventory(
db,
tenant_no=tenant_no,
plan=plan,
inventory=inventory,
dibang_date=dibang_date,
contributed_qty=qty,
user_id=user_id,
dept_id=dept_id or "",
strict_monthly=True,
)
link.db_purchase_plan_id = new_db_plan.id
inventory.purchase_plan_id = new_db_plan.id
inventory.plan_no = (new_db_plan.plan_no or "").strip() or inventory.plan_no
remounted = True
except ValueError as exc:
warnings.append(f"出入库单 {label} 往来/产品改挂失败:{exc}")
elif plate_changed and link.db_purchase_plan_id:
db_plan = (
db.query(DbPurchasePlan)
.filter(
DbPurchasePlan.id == link.db_purchase_plan_id,
DbPurchasePlan.tenant_no == tenant_no,
DbPurchasePlan.del_flag == 0,
)
.with_for_update().first()
)
if db_plan:
db_plan.vehicle_no = plate
db_plan.update_by = user_id
# —— date:同月仅改日期;跨月改挂(本轮已因往来/产品改挂则不再二次扣量)——
if old_date != dibang_date:
same_month = (
old_date is not None
and old_date.year == dibang_date.year
and old_date.month == dibang_date.month
)
inventory.date = dibang_date
if not same_month and not remounted:
qty = _resolve_link_contributed_qty(link, inventory)
_subtract_plan_contribution(
db,
tenant_no=tenant_no,
link=link,
inventory=inventory,
user_id=user_id, current_read=True,
)
try:
new_db_plan = _ensure_monthly_db_plan_for_inventory(
db,
tenant_no=tenant_no,
plan=plan,
inventory=inventory,
dibang_date=dibang_date,
contributed_qty=qty,
user_id=user_id,
dept_id=dept_id or "",
strict_monthly=True,
)
link.db_purchase_plan_id = new_db_plan.id
inventory.purchase_plan_id = new_db_plan.id
inventory.plan_no = (new_db_plan.plan_no or "").strip() or inventory.plan_no
except ValueError as exc:
warnings.append(f"出入库单 {label} 跨月改挂失败:{exc}")
desired_remark = f"运输计划 {plan.plan_no}"
inventory.remark = desired_remark
link.remark = desired_remark
inventory.update_by = user_id
link.update_by = user_id
_sync_weighing_slip_business_fields(db, inventory=inventory, user_id=user_id)
return DibangInventoryLinkResult(
plan_product_id=product.plan_product_id,
product_name=product.product_name or "",
db_purchase_plan_id=link.db_purchase_plan_id or "",
db_inventory_id=inventory.id,
batch_no=inventory.batch_no or "",
plan_no=(inventory.plan_no or plan.plan_no) or "",
)
def _resolve_link_dibang_date(
plan: PlPlan,
link: PlTransportDbLink,
load: Optional[PlPlanLoad],
deliveries: list[PlPlanDelivery],
) -> date:
stop_type = (link.stop_type or "").strip()
if stop_type == "load":
return _resolve_stop_dibang_date(
plan,
stop_type="load",
load_time=load.load_time if load else None,
)
if stop_type == "delivery":
delivery = next((row for row in deliveries if row.id == link.stop_id), None)
if delivery is None and len(deliveries) == 1:
delivery = deliveries[0]
return _resolve_stop_dibang_date(
plan,
stop_type="delivery",
delivery_time=delivery.delivery_time if delivery else None,
)
return _resolve_dibang_date(plan)
def _ensure_monthly_db_plan_for_inventory(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
inventory: DbInboundOutbound,
dibang_date: date,
contributed_qty: float,
user_id: Optional[str],
dept_id: str,
strict_monthly: bool = False,
) -> DbPurchasePlan:
business_type = (inventory.business_type or "").strip()
partner = (inventory.partner_company or "").strip()
product_id = (inventory.product_id or "").strip()
if not business_type or not product_id:
raise ValueError("出入库单缺少业务类型或产品,无法改挂地磅月计划")
find_target = _find_stop_monthly_plan if strict_monthly else find_monthly_db_plan
existing = find_target(
db,
tenant_no,
dibang_date,
business_type,
partner,
product_id,
inventory.specification,
)
if existing:
if contributed_qty:
_apply_plan_quantity_delta(existing, contributed_qty, user_id=user_id)
return existing
month_start, month_end = _month_range(dibang_date)
product = PlPlanProduct(
id=generate_uuid(),
product_id=product_id,
product_name=inventory.product or "",
model=inventory.specification,
unit="吨",
quantity=Decimal(str(contributed_qty or inventory.quantity or 0)),
)
return _create_db_plan(
db,
tenant_no,
plan,
product,
business_type,
partner,
dibang_date,
month_start,
month_end,
user_id,
dept_id,
contributed_qty or float(inventory.quantity or 0),
)
def sync_linked_dibang_dates_for_plan(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
user_id: Optional[str],
dept_id: str = "",
) -> list[str]:
"""计划保存后按当前段次/计划日期回写可编辑出入库单日期;跨月则改挂月计划。
不可回写时不抛错,返回 warnings。
"""
links = (
db.query(PlTransportDbLink)
.filter(
PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.plan_id == plan.id,
PlTransportDbLink.del_flag == 0,
)
.all()
)
if not links:
return []
load = (
db.query(PlPlanLoad)
.filter(
PlPlanLoad.plan_id == plan.id,
PlPlanLoad.tenant_no == tenant_no,
PlPlanLoad.del_flag == 0,
)
.first()
)
deliveries = (
db.query(PlPlanDelivery)
.filter(
PlPlanDelivery.plan_id == plan.id,
PlPlanDelivery.tenant_no == tenant_no,
PlPlanDelivery.del_flag == 0,
)
.order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
.all()
)
warnings: list[str] = []
for link in links:
inventory = (
db.query(DbInboundOutbound)
.filter(
DbInboundOutbound.id == link.db_inventory_id,
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
)
.first()
)
if not inventory:
continue
new_date = _resolve_link_dibang_date(plan, link, load, deliveries)
old_date = _normalize_to_date(inventory.date)
if old_date == new_date:
continue
label = inventory.batch_no or inventory.id
if not _inventory_date_editable(db, inventory):
status = (inventory.status or "").strip() or "未知状态"
if _inventory_has_weighing_slip(db, inventory.id):
reason = "已关联地磅单"
else:
reason = f"状态:{status}"
warnings.append(
f"出入库单 {label}({reason})未同步日期至 {new_date.isoformat()},请在地磅管理中手工处理"
)
continue
same_month = (
old_date is not None
and old_date.year == new_date.year
and old_date.month == new_date.month
)
inventory.date = new_date
inventory.update_by = user_id
if same_month:
continue
qty = _resolve_link_contributed_qty(link, inventory)
_subtract_plan_contribution(
db,
tenant_no=tenant_no,
link=link,
inventory=inventory,
user_id=user_id,
)
try:
new_db_plan = _ensure_monthly_db_plan_for_inventory(
db,
tenant_no=tenant_no,
plan=plan,
inventory=inventory,
dibang_date=new_date,
contributed_qty=qty,
user_id=user_id,
dept_id=dept_id or "",
)
except ValueError as exc:
warnings.append(f"出入库单 {label} 跨月改挂失败:{exc}")
continue
link.db_purchase_plan_id = new_db_plan.id
link.update_by = user_id
inventory.purchase_plan_id = new_db_plan.id
inventory.plan_no = (new_db_plan.plan_no or "").strip() or inventory.plan_no
db.flush()
return warnings
def _sync_stop_inventory_links(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
transport: PlTransport,
stop_type: str,
stop_id: str,
partner: str,
business_type: str,
dibang_date: date,
stop_products: list[_StopDibangProduct],
user_id: Optional[str],
dept_id: str,
warnings: Optional[list[str]] = None,
) -> list[DibangInventoryLinkResult]:
"""按段产品 upsert 出入库+link:已有则就地更新,无则新建。"""
if not partner:
raise ValueError("无法解析往来单位,请检查段客户信息")
month_start, month_end = _month_range(dibang_date)
license_plate = (transport.license_plate or plan.license_plate or "").strip()
results: list[DibangInventoryLinkResult] = []
warn_buf = warnings if warnings is not None else []
for product in stop_products:
if not product.product_id:
raise ValueError(f"产品【{product.product_name}】未关联 CRM 产品,无法生成地磅出入库单")
contributed_qty = product.quantity
existing_link = _find_active_stop_link(
db,
tenant_no=tenant_no,
transport_id=transport.id,
plan_id=plan.id,
stop_type=stop_type,
stop_id=stop_id,
plan_product_id=product.plan_product_id,
)
if existing_link:
inventory = (
db.query(DbInboundOutbound)
.filter(
DbInboundOutbound.id == existing_link.db_inventory_id,
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
)
.with_for_update().first()
)
if inventory:
results.append(
_update_existing_stop_inventory(
db,
tenant_no=tenant_no,
plan=plan,
link=existing_link,
inventory=inventory,
product=product,
partner=partner,
license_plate=license_plate,
dibang_date=dibang_date,
user_id=user_id,
dept_id=dept_id,
warnings=warn_buf,
)
)
continue
raise ValueError("地磅关联的出入库单缺失或已删除,请先核对历史数据")
if contributed_qty == 0:
continue
plan_product = PlPlanProduct(
id=product.plan_product_id,
product_id=product.product_id,
product_name=product.product_name,
model=product.model,
unit=product.unit,
quantity=Decimal(str(contributed_qty)),
)
snapshot = resolve_crm_product(db, tenant_no, product.product_id, require_on_shelf=False)
db_plan = _find_stop_monthly_plan(
db,
tenant_no,
dibang_date,
business_type,
partner,
product.product_id,
snapshot.specification,
)
if not db_plan:
db_plan = _create_db_plan(
db,
tenant_no,
plan,
plan_product,
business_type,
partner,
dibang_date,
month_start,
month_end,
user_id,
dept_id,
contributed_qty,
)
else:
_apply_plan_quantity_delta(db_plan, contributed_qty, user_id=user_id)
inventory = _create_db_inventory(
db,
tenant_no,
plan,
plan_product,
db_plan,
business_type,
partner,
license_plate,
dibang_date,
user_id,
dept_id,
)
link = PlTransportDbLink(
id=generate_uuid(),
tenant_no=tenant_no,
transport_id=transport.id,
plan_id=plan.id,
plan_product_id=product.plan_product_id,
stop_type=stop_type,
stop_id=stop_id,
db_purchase_plan_id=db_plan.id if db_plan else "",
db_inventory_id=inventory.id,
contributed_qty=Decimal(str(contributed_qty)),
remark=f"运输计划 {plan.plan_no}",
create_by=user_id,
create_dept_id=dept_id,
)
db.add(link)
results.append(
DibangInventoryLinkResult(
plan_product_id=product.plan_product_id,
product_name=product.product_name or "",
db_purchase_plan_id=db_plan.id if db_plan else "",
db_inventory_id=inventory.id,
batch_no=inventory.batch_no or "",
plan_no=(db_plan.plan_no if db_plan else plan.plan_no) or "",
)
)
return results
def _resolve_stop_dibang_date(
plan: PlPlan,
*,
stop_type: str,
load_time: date | datetime | str | None = None,
delivery_time: date | datetime | str | None = None,
) -> date:
if stop_type == "load":
resolved = _normalize_to_date(load_time)
if resolved is None:
resolved = _normalize_to_date(plan.pickup_time)
else:
resolved = _normalize_to_date(delivery_time)
if resolved is None:
resolved = _normalize_to_date(plan.delivery_time)
return resolved or _normalize_plan_date(plan)
def _aggregate_stop_products(products: list[_StopDibangProduct]) -> list[_StopDibangProduct]:
"""桥接只有主产品关联键;同键多包装合并数量,不猜配冲突身份。"""
grouped = {}
quantities = {}
for product in products:
key = product.plan_product_id
qty = Decimal(str(product.quantity))
if not key or not product.product_id or not qty.is_finite() or qty < 0:
raise ValueError("段产品关联或计划数量无效,请先核对明细")
previous = grouped.get(key)
if previous and any((getattr(previous, field) or "").strip() !=
(getattr(product, field) or "").strip()
for field in ("product_id", "model", "unit")):
raise ValueError("同一计划产品的段明细产品、规格或单位不一致,不能合并地磅贡献量")
if previous is None:
grouped[key] = _StopDibangProduct(**vars(product))
quantities[key] = Decimal("0")
quantities[key] += qty
for key, product in grouped.items():
product.quantity = float(quantities[key])
return list(grouped.values())
def _refresh_clean_locked(db: Session, row) -> None:
# 计划保存调用前可能已改日期/反馈;只刷新未修改对象,不能吞掉 pending 字段。
if row not in db.new and not db.is_modified(row, include_collections=False):
db.refresh(row, with_for_update=True)
def _preflight_stop_dibang_request(db: Session, *, tenant_no: str, plan: PlPlan,
transport: PlTransport, stop_options: list[StopDibangOption]) -> None:
"""整批先锁定、验证;不修改勾选、月数量、关联或出入库单。"""
if plan.tenant_no != tenant_no or plan.del_flag == 1 or transport.tenant_no != tenant_no or transport.del_flag == 1:
raise ValueError("运输单或计划无效,无法同步地磅")
if plan.transport_id and plan.transport_id != transport.id:
raise ValueError("运输单不属于当前计划")
with db.no_autoflush:
if db.query(PlPlan.id).filter(PlPlan.id == plan.id, PlPlan.tenant_no == tenant_no,
PlPlan.del_flag == 0).with_for_update().first() is None:
raise ValueError("计划不存在或已删除")
_refresh_clean_locked(db, plan)
if plan.transport_id and plan.transport_id != transport.id:
raise ValueError("运输单不属于当前计划")
owner = db.query(PlTransport.id).filter(PlTransport.id == transport.id,
PlTransport.tenant_no == tenant_no, PlTransport.del_flag == 0).with_for_update().first()
if owner is None and transport not in db.new:
raise ValueError("运输单不存在或已删除")
if owner is not None:
_refresh_clean_locked(db, transport)
# 新运输尚未 INSERT 时由上述计划锁保护;通过验证后才 flush。
stops = {}
for kind, model in (("load", PlPlanLoad), ("delivery", PlPlanDelivery)):
rows = db.query(model).filter(model.tenant_no == tenant_no, model.plan_id == plan.id,
model.del_flag == 0).with_for_update().all()
rows.extend(row for row in db.new if isinstance(row, model) and row.tenant_no == tenant_no
and row.plan_id == plan.id)
for row in rows:
_refresh_clean_locked(db, row)
if row.del_flag != 1:
stops[(kind, row.id)] = row
seen, requested = set(), []
for option in stop_options:
key = option.stop_type, option.stop_id
if key in seen or key not in stops:
raise ValueError("停靠段重复、不存在或不属于当前计划")
seen.add(key)
stop = stops[key]
generate, direction = normalize_stop_dibang_fields(stop.customer_name, option.stop_type,
int(option.generate_dibang), stop.dibang_direction)
requested.append((option, stop, generate, direction))
plan_products = {row.id: row for row in db.query(PlPlanProduct).filter(
PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.plan_id == plan.id,
PlPlanProduct.del_flag == 0).with_for_update().all()}
plan_products.update({row.id: row for row in db.new if isinstance(row, PlPlanProduct)
and row.tenant_no == tenant_no and row.plan_id == plan.id})
for row in plan_products.values():
_refresh_clean_locked(db, row)
source_products = {}
for option, stop, generate, direction in requested:
model = PlPlanLoadProduct if option.stop_type == "load" else PlPlanDeliveryProduct
parent_field = "load_id" if option.stop_type == "load" else "delivery_id"
rows = db.query(model).filter(model.tenant_no == tenant_no,
getattr(model, parent_field) == stop.id, model.del_flag == 0).with_for_update().all()
rows.extend(row for row in db.new if isinstance(row, model) and row.tenant_no == tenant_no
and getattr(row, parent_field) == stop.id)
rows = [row for row in rows if row.del_flag != 1]
products = []
for row in rows:
_refresh_clean_locked(db, row)
pp = plan_products.get(row.plan_product_id)
if row.plan_id != plan.id or pp is None or pp.del_flag == 1 or pp.product_id != row.product_id:
raise ValueError("段产品不属于当前计划有效主产品")
products.append(_StopDibangProduct(row.plan_product_id, row.product_id,
row.product_name or "", row.model, row.unit or "吨", _stop_product_quantity(row)))
source_products[(option.stop_type, stop.id)] = _aggregate_stop_products(products)
if generate and not products:
raise ValueError("停靠段无产品明细,无法生成地磅单")
product_ids = sorted({p.product_id for products in source_products.values() for p in products})
masters = db.query(CrmProduct).filter(CrmProduct.tenant_no == tenant_no,
CrmProduct.id.in_(product_ids)).order_by(CrmProduct.id).with_for_update().all()
for row in masters:
_refresh_clean_locked(db, row)
if {row.id for row in masters if row.del_flag == 0} != set(product_ids):
raise ValueError("段产品关联的 CRM 产品不存在或已删除")
load = next((stop for (kind, _), stop in stops.items() if kind == "load"), None)
deliveries = sorted([stop for (kind, _), stop in stops.items() if kind == "delivery"],
key=lambda row: (row.seq or 0, row.id))
contributions = {}
for option, stop, generate, direction in requested:
links = db.query(PlTransportDbLink).filter(PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.transport_id == transport.id,
PlTransportDbLink.stop_id == stop.id, PlTransportDbLink.del_flag == 0).with_for_update().all()
keys, existing_directions = set(), {}
for link in links:
_refresh_clean_locked(db, link)
if link.plan_id != plan.id or link.stop_type != option.stop_type or link.plan_product_id in keys:
raise ValueError("段产品地磅关联归属异常或重复,请先核对历史数据")
keys.add(link.plan_product_id)
if link.plan_product_id not in plan_products or plan_products[link.plan_product_id].del_flag == 1:
raise ValueError("地磅关联主产品无效,请先核对历史数据")
inventory = db.query(DbInboundOutbound).filter(DbInboundOutbound.id == link.db_inventory_id,
DbInboundOutbound.tenant_no == tenant_no, DbInboundOutbound.del_flag == 0).with_for_update().first()
month = db.query(DbPurchasePlan).filter(DbPurchasePlan.id == link.db_purchase_plan_id,
DbPurchasePlan.tenant_no == tenant_no, DbPurchasePlan.del_flag == 0).with_for_update().first()
if inventory is None or month is None:
raise ValueError("地磅关联的出入库单或月计划缺失、已删除,请先核对历史数据")
_refresh_clean_locked(db, inventory)
_refresh_clean_locked(db, month)
if inventory.purchase_plan_id != month.id or any(
(getattr(month, key) or "").strip() != (getattr(inventory, other) or "").strip()
for key, other in (("business_type", "business_type"), ("product_id", "product_id"),
("customer_name", "partner_company"), ("specification", "specification"))
):
raise ValueError("地磅关联的出入库单与月计划不一致")
existing_directions[link.plan_product_id] = inventory.business_type
users = db.query(PlTransportDbLink.id).filter(PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.db_inventory_id == inventory.id, PlTransportDbLink.del_flag == 0).with_for_update().all()
if len(users) != 1:
raise ValueError("出入库单存在多个有效地磅关联,请先核对历史数据")
old_qty = Decimal(str(_resolve_link_contributed_qty(link, inventory)))
contributions[month.id] = contributions.get(month.id, Decimal("0")) + old_qty
if not old_qty.is_finite() or old_qty < 0 or contributions[month.id] > Decimal(str(month.planned_quantity or 0)):
raise ValueError("地磅月计划贡献量异常,请先核对历史数据")
if not generate and ((inventory.status or "").strip() not in _REVOKABLE_INVENTORY_STATUSES
or _inventory_has_weighing_slip(db, inventory.id, tenant_no=tenant_no, current_read=True)):
raise ValueError(f"出入库单 {inventory.batch_no or inventory.id} 已执行或已关联地磅,无法取消生成地磅")
if generate:
partner = resolve_stop_dibang_partner(option.stop_type, load, deliveries)
if not partner:
raise ValueError("无法解析地磅往来单位,请先配置停靠段客户")
# 提前解析所有产品快照和目标月计划;后段错误不得先写前段。
when = _resolve_stop_dibang_date(plan, stop_type=option.stop_type,
load_time=getattr(stop, "load_time", None), delivery_time=getattr(stop, "delivery_time", None))
for product in source_products[(option.stop_type, stop.id)]:
snapshot = resolve_crm_product(db, tenant_no, product.product_id, require_on_shelf=False)
target = _find_stop_monthly_plan(db, tenant_no, when,
existing_directions.get(product.plan_product_id) or _direction_to_business_type(direction),
partner, snapshot.product_id, snapshot.specification)
if target is not None:
_refresh_clean_locked(db, target)
def _load_stop_products(
db: Session,
tenant_no: str,
load_id: str,
) -> list[_StopDibangProduct]:
rows = (
db.query(PlPlanLoadProduct)
.filter(
PlPlanLoadProduct.load_id == load_id,
PlPlanLoadProduct.tenant_no == tenant_no,
PlPlanLoadProduct.del_flag == 0,
)
.order_by(PlPlanLoadProduct.order_num.asc(), PlPlanLoadProduct.create_time.asc())
.with_for_update().all()
)
return _aggregate_stop_products([
_StopDibangProduct(
plan_product_id=row.plan_product_id,
product_id=row.product_id,
product_name=row.product_name or "",
model=row.model,
unit=row.unit or "吨",
quantity=_stop_product_quantity(row),
)
for row in rows
])
def _delivery_stop_products(
db: Session,
tenant_no: str,
delivery_id: str,
) -> list[_StopDibangProduct]:
rows = (
db.query(PlPlanDeliveryProduct)
.filter(
PlPlanDeliveryProduct.delivery_id == delivery_id,
PlPlanDeliveryProduct.tenant_no == tenant_no,
PlPlanDeliveryProduct.del_flag == 0,
)
.order_by(PlPlanDeliveryProduct.order_num.asc(), PlPlanDeliveryProduct.create_time.asc())
.with_for_update().all()
)
return _aggregate_stop_products([
_StopDibangProduct(
plan_product_id=row.plan_product_id,
product_id=row.product_id,
product_name=row.product_name or "",
model=row.model,
unit=row.unit or "吨",
quantity=_stop_product_quantity(row),
)
for row in rows
])
def upsert_stop_dibang_links(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
transport: PlTransport,
stop_options: list[StopDibangOption],
user_id: Optional[str],
dept_id: str,
) -> tuple[list[DibangInventoryLinkResult], list[str]]:
"""按段 upsert 地磅出入库与 link:新建 / 就地更新 / 取消勾选软删。
返回 (link_results, warnings)。产品/往来变更就地同步(跨月改挂完整逻辑见 Task 3);
已过磅取消勾选时抛 ValueError。
"""
_preflight_stop_dibang_request(db, tenant_no=tenant_no, plan=plan,
transport=transport, stop_options=stop_options)
# 验证全部通过后才保存调用方 pending 的新段/新运输,供后续查询使用。
db.flush()
apply_stop_dibang_options(
db,
tenant_no=tenant_no,
plan_id=plan.id,
stop_options=stop_options,
user_id=user_id,
)
option_map = {(item.stop_type, item.stop_id): item for item in stop_options}
results: list[DibangInventoryLinkResult] = []
warnings: list[str] = []
deliveries = (
db.query(PlPlanDelivery)
.filter(
PlPlanDelivery.plan_id == plan.id,
PlPlanDelivery.tenant_no == tenant_no,
PlPlanDelivery.del_flag == 0,
)
.order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
.with_for_update().all()
)
load = (
db.query(PlPlanLoad)
.filter(
PlPlanLoad.plan_id == plan.id,
PlPlanLoad.tenant_no == tenant_no,
PlPlanLoad.del_flag == 0,
)
.with_for_update().first()
)
def _handle_stop(
*,
stop_type: Literal["load", "delivery"],
stop: PlPlanLoad | PlPlanDelivery,
partner: str,
dibang_date: date,
) -> None:
option = option_map.get((stop_type, stop.id))
if option is None:
return
# 以 apply_stop_dibang_options 归一化后的段字段为准(外部客户会被强制清勾选)
if not int(getattr(stop, "generate_dibang", 0) or 0):
_cancel_stop_dibang_links(
db,
tenant_no=tenant_no,
transport_id=transport.id,
plan_id=plan.id,
stop_type=stop_type,
stop_id=stop.id,
user_id=user_id,
)
return
if stop_type == "load":
stop_products = _load_stop_products(db, tenant_no, stop.id)
else:
stop_products = _delivery_stop_products(db, tenant_no, stop.id)
business_type = _resolve_stop_business_type(stop, stop_type)
if not business_type:
stop_label = getattr(stop, "customer_name", None) or stop.id
raise ValueError(f"{'装货' if stop_type == 'load' else '送货'}段【{stop_label}】未配置地磅方向,无法生成地磅单")
if not stop_products:
stop_label = getattr(stop, "customer_name", None) or stop.id
raise ValueError(f"{'装货' if stop_type == 'load' else '送货'}段【{stop_label}】无产品明细,无法生成地磅单")
if not partner:
raise ValueError(
"无法解析装货段地磅往来单位,请先配置送货客户"
if stop_type == "load"
else "无法解析送货段地磅往来单位,请先配置装货客户"
)
results.extend(
_sync_stop_inventory_links(
db,
tenant_no=tenant_no,
plan=plan,
transport=transport,
stop_type=stop_type,
stop_id=stop.id,
partner=partner,
business_type=business_type,
dibang_date=dibang_date,
stop_products=stop_products,
user_id=user_id,
dept_id=dept_id,
warnings=warnings,
)
)
if load:
_handle_stop(
stop_type="load",
stop=load,
partner=resolve_stop_dibang_partner("load", load, deliveries),
dibang_date=_resolve_stop_dibang_date(
plan,
stop_type="load",
load_time=load.load_time,
),
)
for delivery in deliveries:
_handle_stop(
stop_type="delivery",
stop=delivery,
partner=resolve_stop_dibang_partner("delivery", load, deliveries),
dibang_date=_resolve_stop_dibang_date(
plan,
stop_type="delivery",
delivery_time=delivery.delivery_time,
),
)
db.flush()
return results, warnings
def sync_dibang_for_plan_stops(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
transport: PlTransport,
stop_options: list[StopDibangOption],
user_id: Optional[str],
dept_id: str,
) -> list[DibangInventoryLinkResult]:
"""按装货/送货段勾选 upsert 地磅计划与出入库单(兼容旧调用方,忽略 warnings)。"""
results, _warnings = upsert_stop_dibang_links(
db,
tenant_no=tenant_no,
plan=plan,
transport=transport,
stop_options=stop_options,
user_id=user_id,
dept_id=dept_id,
)
return results
def _legacy_monthly_plan(db, tenant_no, dibang_date, business_type, partner, snapshot):
"""The legacy bridge must not guess between overlapping monthly plans."""
start, end = _month_range(dibang_date)
rows = db.query(DbPurchasePlan).filter(
DbPurchasePlan.tenant_no == tenant_no, DbPurchasePlan.del_flag == 0,
DbPurchasePlan.business_type == business_type,
DbPurchasePlan.product_id == snapshot.product_id,
func.trim(func.coalesce(DbPurchasePlan.customer_name, "")) == partner,
func.trim(func.coalesce(DbPurchasePlan.specification, "")) == (snapshot.specification or ""),
DbPurchasePlan.start_date <= end, DbPurchasePlan.end_date >= start,
).populate_existing().with_for_update().limit(2).all()
if len(rows) > 1:
raise ValueError("存在重复地磅月计划,请先核对历史关联")
return rows[0] if rows else None
def _sync_transport_inventory_links(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
products: list[PlPlanProduct],
transport: PlTransport,
user_id: Optional[str],
dept_id: str,
) -> list[DibangInventoryLinkResult]:
business_type = _plan_type_to_business_type(plan.plan_type)
if not business_type:
return []
partner = resolve_partner_company(plan, plan.plan_type)
if not partner:
raise ValueError("无法解析往来单位,请检查合同供方/需方信息")
dibang_date = _resolve_dibang_date(plan)
month_start, month_end = _month_range(dibang_date)
license_plate = (transport.license_plate or plan.license_plate or "").strip()
jobs, targets, seen, old_totals = [], {}, set(), {}
# Serialize retries for one transport, including the first call with no link yet.
# No child/quantity mutation occurs until every requested product is validated.
# Persist this request's pending fields in the same transaction before refreshing
# locked identities; never discard an unflushed feedback/header edit. No commit.
db.flush()
with db.no_autoflush:
owner = db.query(PlTransport).filter(
PlTransport.id == transport.id, PlTransport.tenant_no == tenant_no,
PlTransport.del_flag == 0,
).populate_existing().with_for_update().first()
if owner is None or plan.tenant_no != tenant_no or plan.del_flag != 0:
raise ValueError("运输单或计划无效,无法同步地磅")
# Different transports may create the same monthly bucket concurrently.
# Stable sorted product locks also protect the initially absent bucket.
product_ids = sorted({product.product_id for product in products if product.product_id})
db.query(CrmProduct).filter(
CrmProduct.tenant_no == tenant_no, CrmProduct.id.in_(product_ids),
).order_by(CrmProduct.id).populate_existing().with_for_update().all()
for product in products:
if (not product.id or product.id in seen or product.tenant_no != tenant_no
or product.plan_id != plan.id or product.del_flag != 0):
raise ValueError("计划产品重复、无效或不属于当前计划")
seen.add(product.id)
if not product.product_id:
raise ValueError(f"产品【{product.product_name}】未关联 CRM 产品,无法生成地磅出入库单")
snapshot = resolve_crm_product(db, tenant_no, product.product_id, require_on_shelf=False)
quantity = Decimal(str(_product_quantity(product)))
if not quantity.is_finite() or quantity < 0:
raise ValueError("地磅计划数量须为非负有限数")
links = db.query(PlTransportDbLink).filter(
PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.transport_id == transport.id,
PlTransportDbLink.plan_product_id == product.id,
).populate_existing().with_for_update().all()
if len(links) > 1:
raise ValueError("运输产品存在重复地磅关联,请先核对历史数据")
link = links[0] if links else None
inventory, old_plan, protected = None, None, False
old_quantity = Decimal("0")
desired = dict(date=dibang_date, partner_company=partner, business_type=business_type,
vehicle_no=license_plate, product_id=snapshot.product_id,
product=snapshot.product_name, specification=snapshot.specification)
if link is not None:
if link.del_flag != 0 or link.plan_id != plan.id or link.stop_type or link.stop_id:
raise ValueError("地磅关联已删除或不属于当前旧版计划,请先核对历史数据")
inventory = db.query(DbInboundOutbound).filter(
DbInboundOutbound.id == link.db_inventory_id,
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
).populate_existing().with_for_update().first()
old_plan = db.query(DbPurchasePlan).filter(
DbPurchasePlan.id == link.db_purchase_plan_id,
DbPurchasePlan.tenant_no == tenant_no, DbPurchasePlan.del_flag == 0,
).populate_existing().with_for_update().first()
if inventory is None or old_plan is None or inventory.purchase_plan_id != old_plan.id:
raise ValueError("地磅关联的出入库单或月计划缺失、已删除或不一致")
users = db.query(PlTransportDbLink.id).filter(
PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.db_inventory_id == inventory.id,
PlTransportDbLink.del_flag == 0,
).all()
if len(users) != 1:
raise ValueError("出入库单存在多个有效运输关联,请先核对历史数据")
if any((getattr(old_plan, key) or "").strip() != (getattr(inventory, other) or "").strip()
for key, other in (("business_type", "business_type"), ("product_id", "product_id"),
("customer_name", "partner_company"), ("specification", "specification"))):
raise ValueError("地磅月计划与出入库业务信息不一致,请先核对历史数据")
protected = (_inventory_has_weighing_slip(db, inventory.id)
or (inventory.status or "").strip() not in _REVOKABLE_INVENTORY_STATUSES)
if protected and link.contributed_qty is None:
raise ValueError("已有称重或执行记录但缺少原计划贡献量,不能自动同步")
old_quantity = Decimal(str(_resolve_link_contributed_qty(link, inventory)))
if (not old_quantity.is_finite() or old_quantity < 0
or float(old_plan.planned_quantity or 0) + 1e-9 < float(old_quantity)):
raise ValueError("地磅月计划贡献量异常,请先核对历史数据")
old_totals[old_plan.id] = old_totals.get(old_plan.id, Decimal("0")) + old_quantity
if float(old_totals[old_plan.id]) > float(old_plan.planned_quantity or 0) + 1e-9:
raise ValueError("地磅月计划关联贡献量合计异常,请先核对历史数据")
changed = any((getattr(inventory, key) or "") != (value or "")
for key, value in desired.items()) or quantity != old_quantity
if protected and changed:
raise ValueError(f"出入库单 {inventory.batch_no or inventory.id} 已过磅或已执行,不能修改关联业务信息")
target_key = (dibang_date.year, dibang_date.month, business_type, partner,
snapshot.product_id, snapshot.specification or "")
if target_key not in targets:
targets[target_key] = _legacy_monthly_plan(
db, tenant_no, dibang_date, business_type, partner, snapshot)
jobs.append((product, link, inventory, old_plan, quantity, old_quantity,
desired, protected, target_key))
results: list[DibangInventoryLinkResult] = []
for product, link, inventory, old_plan, quantity, old_quantity, desired, protected, key in jobs:
if not protected:
db_plan = targets[key]
if db_plan is None:
db_plan = _create_db_plan(db, tenant_no, plan, product, business_type, partner,
dibang_date, month_start, month_end, user_id, dept_id, 0)
targets[key] = db_plan
if old_plan is not None and old_plan.id != db_plan.id:
_apply_plan_quantity_delta(old_plan, -float(old_quantity), user_id=user_id)
delta = quantity - old_quantity if old_plan is not None and old_plan.id == db_plan.id else quantity
if delta:
_apply_plan_quantity_delta(db_plan, float(delta), user_id=user_id)
if inventory is None:
inventory = _create_db_inventory(db, tenant_no, plan, product, db_plan, business_type,
partner, license_plate, dibang_date, user_id, dept_id)
link = PlTransportDbLink(id=generate_uuid(), tenant_no=tenant_no,
transport_id=transport.id, plan_id=plan.id, plan_product_id=product.id,
db_purchase_plan_id=db_plan.id, db_inventory_id=inventory.id,
contributed_qty=quantity, remark=f"运输计划 {plan.plan_no}",
create_by=user_id, create_dept_id=dept_id)
db.add(link)
else:
old_direction = inventory.business_type
for field, value in desired.items():
setattr(inventory, field, value)
if old_direction != business_type:
inventory.status = "待入库" if business_type == "入库" else "待出库"
if quantity != old_quantity:
inventory.quantity = quantity
inventory.purchase_plan_id = db_plan.id
inventory.plan_no = db_plan.plan_no
inventory.update_by = user_id
link.db_purchase_plan_id = db_plan.id
link.contributed_qty = quantity
link.update_by = user_id
results.append(DibangInventoryLinkResult(
plan_product_id=product.id, product_name=product.product_name or "",
db_purchase_plan_id=link.db_purchase_plan_id, db_inventory_id=inventory.id,
batch_no=inventory.batch_no or "", plan_no=inventory.plan_no or plan.plan_no or ""))
db.flush()
return results
def sync_dibang_for_transport(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
products: list[PlPlanProduct],
transport: PlTransport,
user_id: Optional[str],
dept_id: str,
) -> list[DibangInventoryLinkResult]:
"""勾选「生成地磅单」:按产品生成/复用地磅计划,并创建关联出入库单。"""
return _sync_transport_inventory_links(
db,
tenant_no=tenant_no,
plan=plan,
products=products,
transport=transport,
user_id=user_id,
dept_id=dept_id,
)