1811 lines
68 KiB
Python
1811 lines
68 KiB
Python
"""运输计划安排时联动地磅计划与出入库单。"""
|
||
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,
|
||
)
|