Files
shanxia2.0/backend/app/services/pl_plan_service.py
T
2026-06-25 08:06:32 +08:00

868 lines
27 KiB
Python

"""运输计划业务逻辑"""
from datetime import date
from decimal import Decimal
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.pl_models import (
PlCarrier,
PlFreightSettlement,
PlFreightSettlementTransport,
PlPersonnel,
PlPlan,
PlPlanChange,
PlPlanProduct,
PlPlanTransport,
PlTransport,
PlVehicle,
PlVehiclePersonnel,
)
from app.schemas.pl.plan import (
PlanArchiveFreightBlock,
PlanArchiveProductItem,
PlanCreateFull,
PlanProductCreate,
PlanTransportCreateBlock,
)
def determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
if destination_name and ("安庆山峡" in destination_name or "安徽云栋" in destination_name):
return "inbound"
if supplier_name and ("安庆山峡" in supplier_name or "安徽云栋" in supplier_name):
return "outbound"
return "direct"
def is_ton_product_unit(unit: Optional[str]) -> bool:
code = (unit or "").strip()
if not code:
return True
return code in ("dun", "吨")
def _product_quantity(prod: PlanProductCreate) -> Decimal:
if prod.quantity is not None:
return Decimal(str(prod.quantity))
if prod.inbound_estimate_qty is not None:
return Decimal(str(prod.inbound_estimate_qty))
if prod.outbound_estimate_qty is not None:
return Decimal(str(prod.outbound_estimate_qty))
return Decimal("0")
def sum_product_quantities(products: list[PlanProductCreate]) -> Decimal:
return sum(_product_quantity(p) for p in products)
def _estimate_qty_fields(plan_type: str, qty: Optional[Decimal]) -> dict:
if qty is None:
return {}
q = float(qty)
if plan_type == "inbound":
return {"inbound_estimate_qty": q, "outbound_estimate_qty": q}
if plan_type == "outbound":
return {"outbound_estimate_qty": q, "inbound_estimate_qty": q}
return {"inbound_estimate_qty": q, "outbound_estimate_qty": q}
def build_plan_product(
*,
tenant_no: str,
plan_id: str,
plan_type: str,
prod: PlanProductCreate,
order_num: int,
user_id: str,
dept_id: str,
) -> PlPlanProduct:
qty = _product_quantity(prod)
estimates = _estimate_qty_fields(plan_type, qty if qty else None)
tax_price = prod.tax_price
return PlPlanProduct(
id=generate_uuid(),
tenant_no=tenant_no,
plan_id=plan_id,
product_id=prod.product_id,
product_no=prod.product_no,
product_name=prod.product_name,
model=prod.model,
unit=prod.unit or "吨",
category_name=prod.category_name,
package_spec=prod.package_spec,
delivery_method=prod.delivery_method,
quantity=qty if qty else None,
actual_qty=Decimal(str(prod.actual_qty)) if prod.actual_qty is not None else (qty if qty else None),
tax_price=tax_price,
tax_amount=prod.tax_amount,
tax_subtotal=prod.tax_subtotal,
is_explosive=prod.is_explosive,
is_toxic=prod.is_toxic,
purchase_cert_url=prod.purchase_cert_url,
transport_cert_url=prod.transport_cert_url,
inbound_price=tax_price,
outbound_price=tax_price,
inbound_amount=prod.tax_subtotal,
outbound_amount=prod.tax_subtotal,
remark=prod.remark,
order_num=order_num,
create_by=user_id,
create_dept_id=dept_id,
**estimates,
)
def _resolve_driver(
db: Session,
tenant_no: str,
vehicle_id: str,
driver_id: Optional[str],
) -> tuple[str, str, Optional[str]]:
if driver_id:
personnel = (
db.query(PlPersonnel)
.filter(
PlPersonnel.id == driver_id,
PlPersonnel.tenant_no == tenant_no,
PlPersonnel.del_flag == 0,
)
.first()
)
if personnel:
return personnel.id, personnel.personnel_name, personnel.phone_no
default_link = (
db.query(PlVehiclePersonnel)
.filter(
PlVehiclePersonnel.vehicle_id == vehicle_id,
PlVehiclePersonnel.tenant_no == tenant_no,
PlVehiclePersonnel.del_flag == 0,
PlVehiclePersonnel.role == "driver",
)
.order_by(PlVehiclePersonnel.is_default.desc(), PlVehiclePersonnel.order_num.asc())
.first()
)
if default_link:
personnel = (
db.query(PlPersonnel)
.filter(
PlPersonnel.id == default_link.personnel_id,
PlPersonnel.del_flag == 0,
)
.first()
)
if personnel:
return personnel.id, personnel.personnel_name, personnel.phone_no
raise HTTPException(status_code=400, detail="请选择司机或确保车辆已关联默认司机")
def _calc_freight_amount(
freight_type: str,
products: list[PlPlanProduct],
transport_qty: Optional[Decimal],
freight_price: Optional[Decimal],
freight_amount: Optional[Decimal],
) -> Decimal:
if freight_type == "by_trip":
return Decimal(str(freight_amount or 0))
qty = transport_qty
if qty is None:
qty = sum(
Decimal(str(p.quantity or p.inbound_estimate_qty or p.outbound_estimate_qty or 0))
for p in products
)
if freight_price is not None:
computed = qty * Decimal(str(freight_price))
if freight_amount is not None:
return Decimal(str(freight_amount))
return computed
return Decimal(str(freight_amount or 0))
def _validate_toxic_products(products: list[PlPlanProduct]) -> None:
for prod in products:
if prod.is_explosive or prod.is_toxic:
if not prod.purchase_cert_url or not prod.transport_cert_url:
label = "易制爆" if prod.is_explosive else "易制毒"
raise HTTPException(
status_code=400,
detail=f"产品【{prod.product_name}】为{label},必须上传购买证和运输证",
)
def resolve_hazard_flags_from_crm(
db: Session,
tenant_no: str,
product_id: Optional[str],
is_toxic: int = 0,
is_explosive: int = 0,
) -> tuple[int, int]:
if is_toxic or is_explosive:
return int(is_toxic or 0), int(is_explosive or 0)
if not product_id:
return 0, 0
from app.models.crm_models import CrmProduct
row = (
db.query(CrmProduct)
.filter(
CrmProduct.id == product_id,
CrmProduct.tenant_no == tenant_no,
CrmProduct.del_flag == 0,
)
.first()
)
if not row:
return 0, 0
return int(row.is_toxic or 0), int(row.is_explosive or 0)
def update_plan_product_certs(
db: Session,
*,
tenant_no: str,
plan_id: str,
items: list,
user_id: str,
) -> list[PlPlanProduct]:
plan = (
db.query(PlPlan)
.filter(
PlPlan.id == plan_id,
PlPlan.tenant_no == tenant_no,
PlPlan.del_flag == 0,
)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
if plan.archive_status == "archived":
raise HTTPException(status_code=400, detail="已归档的计划不能修改")
updated: list[PlPlanProduct] = []
for item in items:
row = (
db.query(PlPlanProduct)
.filter(
PlPlanProduct.id == item.id,
PlPlanProduct.plan_id == plan_id,
PlPlanProduct.tenant_no == tenant_no,
PlPlanProduct.del_flag == 0,
)
.first()
)
if not row:
raise HTTPException(status_code=404, detail=f"计划产品不存在: {item.id}")
if not (row.is_toxic or row.is_explosive):
raise HTTPException(status_code=400, detail=f"产品【{row.product_name}】无需上传危化品证书")
row.purchase_cert_url = (item.purchase_cert_url or "").strip() or None
row.transport_cert_url = (item.transport_cert_url or "").strip() or None
row.update_by = user_id
updated.append(row)
plan.update_by = user_id
db.commit()
for row in updated:
db.refresh(row)
return updated
def _generate_transport_no(db: Session) -> str:
now = date.today()
prefix = f"SXYS{now.strftime('%Y%m')}"
last = (
db.query(PlTransport)
.filter(PlTransport.transport_no.like(f"{prefix}%"))
.order_by(PlTransport.transport_no.desc())
.first()
)
seq = 1
if last and last.transport_no:
try:
seq = int(last.transport_no[-6:]) + 1
except ValueError:
seq = 1
return f"{prefix}{seq:06d}"
def _resolve_transport_context(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
transport_payload: PlanTransportCreateBlock,
products: list[PlPlanProduct],
) -> tuple[PlVehicle, str, str, Optional[str], Optional[str], Optional[str], Decimal, Optional[Decimal]]:
vehicle = (
db.query(PlVehicle)
.filter(
PlVehicle.id == transport_payload.vehicle_id,
PlVehicle.tenant_no == tenant_no,
PlVehicle.del_flag == 0,
)
.first()
)
if not vehicle:
raise HTTPException(status_code=400, detail="车辆不存在")
driver_id, driver_name, driver_phone = _resolve_driver(
db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
)
carrier_id = transport_payload.carrier_id or vehicle.carrier_id
carrier_name = vehicle.carrier_name
if carrier_id:
carrier = (
db.query(PlCarrier)
.filter(PlCarrier.id == carrier_id, PlCarrier.del_flag == 0)
.first()
)
if carrier:
carrier_name = carrier.carrier_name
freight_amount = _calc_freight_amount(
transport_payload.freight_type,
products,
transport_payload.transport_qty,
transport_payload.freight_price,
transport_payload.freight_amount,
)
transport_qty = transport_payload.transport_qty
if transport_qty is None and transport_payload.freight_type == "by_weight":
transport_qty = sum(
Decimal(str(p.quantity or p.inbound_estimate_qty or p.outbound_estimate_qty or 0))
for p in products
)
return (
vehicle,
driver_id,
driver_name,
driver_phone,
carrier_id,
carrier_name,
freight_amount,
transport_qty,
)
def _apply_payload_to_transport_row(
transport: PlTransport,
*,
vehicle: PlVehicle,
driver_id: str,
driver_name: str,
driver_phone: Optional[str],
carrier_id: Optional[str],
carrier_name: Optional[str],
plan: PlPlan,
transport_payload: PlanTransportCreateBlock,
freight_amount: Decimal,
transport_qty: Optional[Decimal],
user_id: str,
) -> None:
transport.vehicle_id = vehicle.id
transport.license_plate = vehicle.license_plate
transport.driver_id = driver_id
transport.driver_name = driver_name
transport.driver_phone = driver_phone
transport.carrier_id = carrier_id
transport.carrier_name = carrier_name
transport.start_point = plan.pickup_address
transport.end_point = plan.destination_address or plan.destination_name
transport.distance_km = transport_payload.distance_km
transport.transport_qty = transport_qty
transport.freight_price = transport_payload.freight_price
transport.freight_amount = freight_amount or 0
transport.freight_type = transport_payload.freight_type
transport.has_invoice = transport_payload.has_invoice
transport.unit = "吨" if transport_payload.freight_type == "by_weight" else "趟"
if transport_payload.remark is not None:
transport.remark = transport_payload.remark
transport.update_by = user_id
def upsert_draft_transport_for_plan(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
transport_payload: PlanTransportCreateBlock,
products: list[PlPlanProduct],
user_id: str,
dept_id: str,
) -> None:
"""写入/更新草稿运输单(pl_transport + pl_plan_transport),计划保持待安排。"""
(
vehicle,
driver_id,
driver_name,
driver_phone,
carrier_id,
carrier_name,
freight_amount,
transport_qty,
) = _resolve_transport_context(
db,
tenant_no=tenant_no,
plan=plan,
transport_payload=transport_payload,
products=products,
)
transport: Optional[PlTransport] = None
if plan.transport_id:
transport = (
db.query(PlTransport)
.filter(
PlTransport.id == plan.transport_id,
PlTransport.tenant_no == tenant_no,
PlTransport.del_flag == 0,
)
.first()
)
if transport:
_apply_payload_to_transport_row(
transport,
vehicle=vehicle,
driver_id=driver_id,
driver_name=driver_name,
driver_phone=driver_phone,
carrier_id=carrier_id,
carrier_name=carrier_name,
plan=plan,
transport_payload=transport_payload,
freight_amount=freight_amount,
transport_qty=transport_qty,
user_id=user_id,
)
if transport.transport_status == "draft":
pass
else:
transport_no = _generate_transport_no(db)
transport = PlTransport(
id=generate_uuid(),
tenant_no=tenant_no,
transport_no=transport_no,
transport_status="draft",
approval_status="none",
remark=transport_payload.remark,
create_by=user_id,
create_dept_id=dept_id,
)
db.add(transport)
db.flush()
db.add(
PlPlanTransport(
id=generate_uuid(),
tenant_no=tenant_no,
plan_id=plan.id,
transport_id=transport.id,
create_by=user_id,
create_dept_id=dept_id,
)
)
plan.transport_id = transport.id
plan.transport_no = transport_no
_apply_payload_to_transport_row(
transport,
vehicle=vehicle,
driver_id=driver_id,
driver_name=driver_name,
driver_phone=driver_phone,
carrier_id=carrier_id,
carrier_name=carrier_name,
plan=plan,
transport_payload=transport_payload,
freight_amount=freight_amount,
transport_qty=transport_qty,
user_id=user_id,
)
plan.vehicle_id = vehicle.id
plan.license_plate = vehicle.license_plate
plan.driver_id = driver_id
plan.driver_name = driver_name
plan.freight_amount = freight_amount or 0
if plan.transport_status != "arranged":
plan.transport_status = "pending"
def clear_draft_transport_for_plan(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
user_id: str,
) -> None:
"""清除计划上的草稿运输单及车辆预选。"""
if plan.transport_id:
transport = (
db.query(PlTransport)
.filter(
PlTransport.id == plan.transport_id,
PlTransport.tenant_no == tenant_no,
PlTransport.del_flag == 0,
)
.first()
)
if transport and transport.transport_status == "draft":
transport.del_flag = 1
transport.update_by = user_id
db.query(PlPlanTransport).filter(
PlPlanTransport.plan_id == plan.id,
PlPlanTransport.transport_id == transport.id,
PlPlanTransport.tenant_no == tenant_no,
PlPlanTransport.del_flag == 0,
).update({"del_flag": 1, "update_by": user_id})
plan.transport_id = None
plan.transport_no = None
plan.vehicle_id = None
plan.license_plate = None
plan.driver_id = None
plan.driver_name = None
plan.freight_amount = 0
if plan.transport_status != "arranged":
plan.transport_status = "pending"
def sync_transport_prefill_for_plan(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
transport_payload: PlanTransportCreateBlock,
products: list[PlPlanProduct],
user_id: str,
dept_id: str = "",
) -> None:
"""同步运输预选到草稿运输子表(pl_transport)。"""
upsert_draft_transport_for_plan(
db,
tenant_no=tenant_no,
plan=plan,
transport_payload=transport_payload,
products=products,
user_id=user_id,
dept_id=dept_id or user_id,
)
def apply_vehicle_prefill_to_plan(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
transport_payload: PlanTransportCreateBlock,
products: list[PlPlanProduct],
user_id: str = "",
dept_id: str = "",
) -> None:
"""兼容旧调用:写入草稿运输子表。"""
upsert_draft_transport_for_plan(
db,
tenant_no=tenant_no,
plan=plan,
transport_payload=transport_payload,
products=products,
user_id=user_id,
dept_id=dept_id or user_id,
)
def create_transport_for_plan(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
products: list[PlPlanProduct],
transport_payload: PlanTransportCreateBlock,
user_id: str,
dept_id: str,
generate_transport_no,
) -> PlTransport:
vehicle = (
db.query(PlVehicle)
.filter(
PlVehicle.id == transport_payload.vehicle_id,
PlVehicle.tenant_no == tenant_no,
PlVehicle.del_flag == 0,
)
.first()
)
if not vehicle:
raise HTTPException(status_code=400, detail="车辆不存在")
_validate_toxic_products(products)
driver_id, driver_name, driver_phone = _resolve_driver(
db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
)
carrier_name = vehicle.carrier_name
carrier_id = transport_payload.carrier_id or vehicle.carrier_id
if carrier_id:
carrier = (
db.query(PlCarrier)
.filter(PlCarrier.id == carrier_id, PlCarrier.del_flag == 0)
.first()
)
if carrier:
carrier_name = carrier.carrier_name
freight_amount = _calc_freight_amount(
transport_payload.freight_type,
products,
transport_payload.transport_qty,
transport_payload.freight_price,
transport_payload.freight_amount,
)
transport_qty = transport_payload.transport_qty
if transport_qty is None and transport_payload.freight_type == "by_weight":
transport_qty = sum(
Decimal(str(p.quantity or p.inbound_estimate_qty or p.outbound_estimate_qty or 0))
for p in products
)
transport_no = generate_transport_no(db)
transport = PlTransport(
id=generate_uuid(),
tenant_no=tenant_no,
transport_no=transport_no,
vehicle_id=vehicle.id,
license_plate=vehicle.license_plate,
driver_id=driver_id,
driver_name=driver_name,
driver_phone=driver_phone,
carrier_id=carrier_id,
carrier_name=carrier_name,
start_point=plan.pickup_address,
end_point=plan.destination_address or plan.destination_name,
distance_km=transport_payload.distance_km,
unit="吨" if transport_payload.freight_type == "by_weight" else "趟",
transport_qty=transport_qty,
freight_price=transport_payload.freight_price,
freight_amount=freight_amount or 0,
freight_type=transport_payload.freight_type,
has_invoice=transport_payload.has_invoice,
transport_status="draft",
approval_status="none",
remark=transport_payload.remark,
create_by=user_id,
create_dept_id=dept_id,
)
db.add(transport)
db.add(
PlPlanTransport(
id=generate_uuid(),
tenant_no=tenant_no,
plan_id=plan.id,
transport_id=transport.id,
create_by=user_id,
create_dept_id=dept_id,
)
)
plan.transport_id = transport.id
plan.transport_no = transport_no
plan.vehicle_id = vehicle.id
plan.license_plate = vehicle.license_plate
plan.driver_id = driver_id
plan.driver_name = driver_name
plan.transport_status = "arranged"
plan.freight_amount = freight_amount or 0
return transport
def validate_create_full(payload: PlanCreateFull) -> None:
if not payload.contract_id:
raise HTTPException(status_code=422, detail="请选择合同")
if not payload.products:
raise HTTPException(status_code=422, detail="请至少保留一条产品明细")
transport = payload.transport
if not transport or not transport.vehicle_id:
return
if payload.has_freight:
if transport.freight_type == "by_weight":
if not transport.freight_price or Decimal(str(transport.freight_price)) <= 0:
raise HTTPException(status_code=422, detail="按重量计费时请填写运输单价")
qty = transport.transport_qty or sum_product_quantities(payload.products)
if qty <= 0:
raise HTTPException(status_code=422, detail="运输量必须大于0")
elif transport.freight_type == "by_trip":
if not transport.freight_amount or Decimal(str(transport.freight_amount)) <= 0:
raise HTTPException(status_code=422, detail="按趟计费时请填写运费金额")
def calc_plan_total_amount(products: list[PlanProductCreate]) -> Decimal:
total = Decimal("0")
for prod in products:
if prod.tax_subtotal is not None:
total += Decimal(str(prod.tax_subtotal))
return total
_LOCKED_SETTLEMENT_STATUSES = ("approving", "approved", "confirmed")
def transport_has_locked_freight_settlement(
db: Session, tenant_no: str, transport_id: Optional[str]
) -> bool:
if not transport_id:
return False
return (
db.query(PlFreightSettlementTransport.id)
.join(
PlFreightSettlement,
PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id,
)
.filter(
PlFreightSettlementTransport.transport_id == transport_id,
PlFreightSettlementTransport.tenant_no == tenant_no,
PlFreightSettlementTransport.del_flag == 0,
PlFreightSettlement.tenant_no == tenant_no,
PlFreightSettlement.del_flag == 0,
PlFreightSettlement.settlement_status.in_(_LOCKED_SETTLEMENT_STATUSES),
)
.first()
is not None
)
def apply_archive_product_quantities(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
items: list[PlanArchiveProductItem],
user_id: str,
) -> None:
product_map = {
row.id: row
for row in db.query(PlPlanProduct).filter(
PlPlanProduct.plan_id == plan.id,
PlPlanProduct.tenant_no == tenant_no,
PlPlanProduct.del_flag == 0,
).all()
}
for item in items:
prod = product_map.get(item.id)
if not prod:
raise HTTPException(status_code=400, detail=f"产品明细不存在: {item.id}")
actual = Decimal(str(item.actual_qty))
prod.actual_qty = actual
price_in = prod.inbound_price or prod.tax_price
price_out = prod.outbound_price or prod.tax_price
if plan.plan_type == "inbound":
prod.inbound_qty = actual
if price_in is not None:
prod.inbound_amount = actual * Decimal(str(price_in))
elif plan.plan_type == "outbound":
prod.outbound_qty = actual
if price_out is not None:
prod.outbound_amount = actual * Decimal(str(price_out))
prod.update_by = user_id
def apply_archive_freight(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
freight: PlanArchiveFreightBlock,
user_id: str,
) -> None:
has_freight = int(freight.has_freight if freight.has_freight is not None else plan.has_freight or 0)
plan.has_freight = has_freight
plan.update_by = user_id
if has_freight != 1:
plan.freight_amount = Decimal("0")
if not plan.transport_id:
return
if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id):
raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费")
transport = (
db.query(PlTransport)
.filter(
PlTransport.id == plan.transport_id,
PlTransport.tenant_no == tenant_no,
PlTransport.del_flag == 0,
)
.first()
)
if not transport:
raise HTTPException(status_code=400, detail="关联运输单不存在")
transport.freight_amount = Decimal("0")
transport.freight_price = None
transport.transport_qty = None
transport.update_by = user_id
return
if not plan.transport_id:
raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id):
raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费")
transport = (
db.query(PlTransport)
.filter(
PlTransport.id == plan.transport_id,
PlTransport.tenant_no == tenant_no,
PlTransport.del_flag == 0,
)
.first()
)
if not transport:
raise HTTPException(status_code=400, detail="关联运输单不存在")
transport.distance_km = freight.distance_km
transport.freight_type = freight.freight_type or "by_weight"
transport.freight_price = freight.freight_price
amount = freight.freight_amount or Decimal("0")
transport.freight_amount = amount
if freight.transport_qty is not None:
transport.transport_qty = freight.transport_qty
if freight.has_invoice is not None:
transport.has_invoice = freight.has_invoice
transport.update_by = user_id
plan.freight_amount = amount
def record_plan_change(
db: Session,
*,
tenant_no: str,
plan_id: str,
change_type: str,
change_content: str,
user_id: str,
user_name: Optional[str],
dept_id: str,
) -> None:
db.add(
PlPlanChange(
id=generate_uuid(),
tenant_no=tenant_no,
plan_id=plan_id,
change_type=change_type,
change_content=change_content,
change_user_id=user_id,
change_user_name=user_name,
create_by=user_id,
create_dept_id=dept_id,
)
)