Files
shanxia2.0/backend/app/services/pl_dibang_bridge_service.py
T
2026-06-21 09:19:07 +08:00

416 lines
12 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 Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbVehicle, DbWeighingSlip
from app.models.pl_models import PlPlan, 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,
)
@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
def resolve_partner_company(plan: PlPlan, plan_type: str) -> str:
if plan_type == "inbound":
return (plan.supplier_name or plan.party_a or "").strip()
if plan_type == "outbound":
return (plan.customer_name or plan.party_b or "").strip()
return ""
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 _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 _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 _ensure_vehicle_exists(db: Session, tenant_no: str, vehicle_no: str) -> None:
vehicle_no = (vehicle_no or "").strip()
if not vehicle_no:
return
exists = (
db.query(DbVehicle.id)
.filter(
DbVehicle.tenant_no == tenant_no,
DbVehicle.del_flag == 0,
DbVehicle.vehicle_no == vehicle_no,
)
.first()
)
if exists:
return
db.add(
DbVehicle(
id=generate_uuid(),
tenant_no=tenant_no,
vehicle_no=vehicle_no,
remark="",
)
)
db.flush()
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 _create_db_plan(
db: Session,
tenant_no: str,
plan: PlPlan,
product: PlPlanProduct,
business_type: str,
customer_name: str,
month_start: date,
month_end: date,
user_id: Optional[str],
dept_id: str,
) -> DbPurchasePlan:
snapshot = resolve_crm_product(db, tenant_no, product.product_id)
db_plan = DbPurchasePlan(
id=generate_uuid(),
tenant_no=tenant_no,
plan_no=generate_plan_no(),
plan_date=plan.plan_date,
start_date=month_start,
end_date=month_end,
business_type=business_type,
planned_quantity=Decimal(str(_product_quantity(product))),
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,
user_id: Optional[str],
dept_id: str,
) -> DbInboundOutbound:
if license_plate:
_ensure_vehicle_exists(db, tenant_no, license_plate)
snapshot = resolve_crm_product(db, tenant_no, product.product_id)
status_value = "待入库" if business_type == "入库" else "待出库"
inventory = DbInboundOutbound(
id=next_inbound_outbound_id(db, tenant_no),
tenant_no=tenant_no,
date=plan.plan_date,
partner_company=customer_name,
business_type=business_type,
quantity=Decimal(str(_product_quantity(product))),
plan_no=(db_plan.plan_no or "").strip() or None,
batch_no=_generate_batch_no(db, tenant_no),
vehicle_no=(license_plate or "").strip(),
status=status_value,
remark=f"运输计划 {plan.plan_no}",
purchase_plan_id=db_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) -> bool:
return (
db.query(DbWeighingSlip.id)
.filter(
DbWeighingSlip.inbound_outbound_id == inventory_id,
DbWeighingSlip.del_flag == 0,
DbWeighingSlip.is_voided == 0,
)
.first()
is not None
)
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:
inventory.del_flag = 1
inventory.update_by = user_id
link.del_flag = 1
link.update_by = user_id
db.flush()
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]:
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("无法解析往来单位,请检查合同供方/需方信息")
plan_date = plan.plan_date
if isinstance(plan_date, datetime):
plan_date = plan_date.date()
elif isinstance(plan_date, str):
parsed = parse_date_str(plan_date)
plan_date = parsed or date.today()
month_start, month_end = _month_range(plan_date)
license_plate = (transport.license_plate or plan.license_plate or "").strip()
# 计划/安排变更后再次勾地磅:先作废本运输单此前可重建的出入库单,再按当前计划重建
revoke_regeneratable_dibang_for_transport(
db,
tenant_no=tenant_no,
transport_id=transport.id,
user_id=user_id,
)
results: list[DibangInventoryLinkResult] = []
for product in products:
if not product.product_id:
raise ValueError(f"产品【{product.product_name}】未关联 CRM 产品,无法生成地磅计划")
db_plan = find_monthly_db_plan(
db,
tenant_no,
plan_date,
business_type,
partner,
product.product_id,
product.model,
)
if not db_plan:
db_plan = _create_db_plan(
db,
tenant_no,
plan,
product,
business_type,
partner,
month_start,
month_end,
user_id,
dept_id,
)
inventory = _create_db_inventory(
db,
tenant_no,
plan,
product,
db_plan,
business_type,
partner,
license_plate,
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,
remark=f"运输计划 {plan.plan_no}",
create_by=user_id,
create_dept_id=dept_id,
)
db.add(link)
results.append(
DibangInventoryLinkResult(
plan_product_id=product.id,
product_name=product.product_name or "",
db_purchase_plan_id=db_plan.id,
db_inventory_id=inventory.id,
batch_no=inventory.batch_no or "",
plan_no=db_plan.plan_no or "",
)
)
return results