| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029 |
- """运输计划管理 - 计划 API"""
- from datetime import date
- from typing import Optional
- from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
- from sqlalchemy import func, or_
- from sqlalchemy.orm import Session
- from app.core.config import settings
- from app.core.database import get_db
- from app.core.db_auth import DbUserContext, get_db_current_user
- from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
- from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
- from app.core.tenant_context import get_tenant_no
- from app.core.utils import generate_uuid
- from app.models.crm_models import CrmContract, CrmContractProduct, CrmProduct
- from app.models.db_models import DbInboundOutbound
- from app.models.pl_models import (
- PlCarrier,
- PlDriver,
- PlPlan,
- PlPlanProduct,
- PlPlanTransport,
- PlTransport,
- PlTransportDbLink,
- PlTransportSiteLink,
- PlVehicle,
- )
- from app.schemas.pl import PlanProductCreate
- from app.schemas.pl.plan import (
- CreateFromContractRequest,
- PlanArchiveBody,
- PlanCreate,
- PlanCreateFull,
- PlanListQuery,
- PlanListResponse,
- PlanProductCertsUpdate,
- PlanProductRead,
- PlanRead,
- PlanUpdate,
- PlanWithProducts,
- )
- from app.schemas.pl.transport import PlanArrangeInfoRead, TransportInventoryLinkRead, TransportSiteLinkRead
- from app.services.pl_dibang_bridge_service import revoke_regeneratable_dibang_for_transport
- from app.services.pl_inventory_link_read import build_transport_inventory_link_read
- from app.services.pl_site_link_read import build_transport_site_link_read_from_row
- from app.services.pl_transport_site_service import revoke_site_links_for_transport
- from app.services.pl_plan_service import (
- apply_archive_freight,
- apply_archive_product_quantities,
- apply_vehicle_prefill_to_plan,
- build_plan_product,
- calc_plan_total_amount,
- clear_draft_transport_for_plan,
- determine_plan_type,
- record_plan_change,
- resolve_hazard_flags_from_crm,
- sync_transport_prefill_for_plan,
- update_plan_product_certs,
- validate_create_full,
- )
- router = APIRouter(prefix="/plan", tags=["运输计划-计划管理"])
- _PLAN_NO_SEQ_WIDTH = 3
- def _generate_plan_no(db: Session) -> str:
- """生成计划编号 SXDDHYYYYMMDD###(当日 3 位序号,每日重置)"""
- today = date.today()
- prefix = f"SXDDH{today.strftime('%Y%m%d')}"
- # uk_plan_no 对全表生效(含软删),取号时不能过滤 del_flag
- rows = (
- db.query(PlPlan.plan_no)
- .filter(PlPlan.plan_no.like(f"{prefix}%"))
- .all()
- )
- max_seq = 0
- for (plan_no,) in rows:
- suffix = (plan_no or "")[len(prefix) :]
- if suffix.isdigit():
- max_seq = max(max_seq, int(suffix))
- seq = max_seq + 1
- return f"{prefix}{seq:0{_PLAN_NO_SEQ_WIDTH}d}"
- def _determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
- """根据供应商和目的地自动判断单据类型"""
- return determine_plan_type(supplier_name, destination_name)
- def _get_plan_type_label(plan_type: str) -> str:
- labels = {
- "direct": "直销单",
- "inbound": "入库单",
- "outbound": "出库单",
- }
- return labels.get(plan_type, plan_type)
- def _generate_notify_message(plan: PlPlan, products: list) -> str:
- """生成通知信息"""
- product_names = "、".join([p.product_name for p in products[:3]])
- if len(products) > 3:
- product_names += "等"
- return f"""计划号:{plan.plan_no}
- 企业名称:{plan.customer_name or ''}
- 提货单位:{plan.destination_name or ''}
- 到货时间:{plan.contract_end_date or ''}
- 药品名称:{product_names}
- 到货吨位:{sum(float(p.inbound_estimate_qty or p.outbound_estimate_qty or 0) for p in products)}吨
- 备注:"""
- # =============================================================================
- # 计划列表
- # =============================================================================
- @router.get("/", response_model=PlanListResponse)
- def list_plans(
- q: Optional[str] = Query(default=None, description="模糊搜索"),
- plan_no: Optional[str] = Query(default=None, description="计划编号"),
- plan_type: Optional[str] = Query(default=None, alias="planType", description="单据类型"),
- follow_status: Optional[str] = Query(default=None, alias="followStatus", description="跟进状态"),
- transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"),
- archive_status: Optional[str] = Query(default=None, alias="archiveStatus", description="归档状态"),
- customer_name: Optional[str] = Query(default=None, alias="customerName", description="客户名称"),
- plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom", description="计划日期开始"),
- plan_date_to: Optional[date] = Query(default=None, alias="planDateTo", description="计划日期结束"),
- page: int = Query(default=1, ge=1),
- page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanListResponse:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- query = db.query(PlPlan).filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- )
- if q:
- query = query.filter(
- or_(
- PlPlan.plan_no.contains(q),
- func.lower(PlPlan.customer_name).contains(q.lower()),
- )
- )
- if plan_no:
- query = query.filter(PlPlan.plan_no.contains(plan_no))
- if plan_type:
- query = query.filter(PlPlan.plan_type == plan_type)
- if follow_status:
- query = query.filter(PlPlan.follow_status == follow_status)
- if transport_status:
- query = query.filter(PlPlan.transport_status == transport_status)
- if archive_status:
- query = query.filter(PlPlan.archive_status == archive_status)
- if customer_name:
- query = query.filter(func.lower(PlPlan.customer_name).contains(customer_name.lower()))
- if plan_date_from:
- query = query.filter(PlPlan.plan_date >= plan_date_from)
- if plan_date_to:
- query = query.filter(PlPlan.plan_date <= plan_date_to)
- query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
- # 统计总数
- total = query.count()
- # 分页
- offset = (page - 1) * page_size
- plans = query.order_by(PlPlan.create_time.desc()).offset(offset).limit(page_size).all()
- plan_ids = [plan.id for plan in plans]
- products_by_plan: dict[str, list[PlPlanProduct]] = {}
- if plan_ids:
- all_products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id.in_(plan_ids),
- PlPlanProduct.del_flag == 0,
- )
- .order_by(PlPlanProduct.order_num)
- .all()
- )
- for product in all_products:
- products_by_plan.setdefault(product.plan_id, []).append(product)
- result_list: list[PlanWithProducts] = []
- for plan in plans:
- enriched_products: list[PlanProductRead] = []
- for product in products_by_plan.get(plan.id, []):
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db,
- tenant_no,
- product.product_id,
- product.is_toxic,
- product.is_explosive,
- )
- item = PlanProductRead.model_validate(product)
- if is_toxic != int(product.is_toxic or 0) or is_explosive != int(product.is_explosive or 0):
- item = item.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
- enriched_products.append(item)
- plan_item = PlanWithProducts.model_validate(plan)
- plan_item.products = enriched_products
- if not (plan_item.personnel_summary or "").strip():
- snapshot = plan_item.resource_snapshot
- if snapshot and snapshot.personnel_summary:
- plan_item.personnel_summary = snapshot.personnel_summary
- elif plan_item.driver_name:
- plan_item.personnel_summary = f"{plan_item.driver_name}(司机)"
- result_list.append(plan_item)
- return PlanListResponse(total=total, list=result_list)
- @router.get("/count-by-contract")
- def count_plans_by_contract(
- contract_ids: str = Query(..., alias="contractIds", description="合同ID,逗号分隔"),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> dict:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- ids = [item.strip() for item in contract_ids.split(",") if item.strip()]
- if not ids:
- return {"counts": {}}
- rows = (
- db.query(PlPlan.contract_id, func.count(PlPlan.id))
- .filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- PlPlan.contract_id.in_(ids),
- )
- )
- rows = apply_pl_scope(rows, PlPlan, user, db, tenant_no).group_by(PlPlan.contract_id).all()
- return {"counts": {contract_id: count for contract_id, count in rows}}
- # =============================================================================
- # 创建计划
- # =============================================================================
- @router.post("/", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
- def create_plan(
- payload: PlanCreate,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanRead:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- # 生成计划编号
- plan_no = _generate_plan_no(db)
- # 自动判断单据类型
- plan_type = payload.plan_type
- if not plan_type:
- plan_type = _determine_plan_type(payload.supplier_name, payload.destination_name)
- plan = PlPlan(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_no=plan_no,
- plan_date=payload.plan_date,
- belongs_company=payload.belongs_company,
- plan_type=plan_type,
- follow_status=payload.follow_status or "draft",
- transport_status=payload.transport_status or "pending",
- archive_status=payload.archive_status or "unarchived",
- complete_status=payload.complete_status or "no",
- contract_id=payload.contract_id,
- contract_no=payload.contract_no,
- supplier_id=payload.supplier_id,
- supplier_name=payload.supplier_name,
- destination_id=payload.destination_id,
- destination_name=payload.destination_name,
- customer_id=payload.customer_id,
- customer_name=payload.customer_name,
- contact_name=payload.contact_name,
- contact_phone=payload.contact_phone,
- party_a=payload.party_a,
- party_b=payload.party_b,
- contract_start_date=payload.contract_start_date,
- contract_end_date=payload.contract_end_date,
- delivery_method=payload.delivery_method,
- settlement_method=payload.settlement_method,
- has_freight=payload.has_freight,
- pickup_address=payload.pickup_address,
- pickup_lng=payload.pickup_lng,
- pickup_lat=payload.pickup_lat,
- pickup_time=payload.pickup_time,
- pickup_notes=payload.pickup_notes,
- destination_address=payload.destination_address,
- destination_lng=payload.destination_lng,
- destination_lat=payload.destination_lat,
- delivery_time=payload.delivery_time,
- destination_notes=payload.destination_notes,
- follow_user_id=payload.follow_user_id or user.id,
- follow_user_name=payload.follow_user_name or user.nick_name,
- remark=payload.remark,
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(plan)
- # 添加产品明细
- if payload.products:
- for idx, prod in enumerate(payload.products):
- pp = build_plan_product(
- tenant_no=tenant_no,
- plan_id=plan.id,
- plan_type=plan_type,
- prod=prod,
- order_num=idx,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- db.add(pp)
- plan.total_amount = calc_plan_total_amount(payload.products or [])
- db.commit()
- db.refresh(plan)
- return plan
- @router.post("/create-full", response_model=PlanWithProducts, status_code=status.HTTP_201_CREATED)
- def create_plan_full(
- payload: PlanCreateFull,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanWithProducts:
- """创建运输计划(完整表单:合同产品 + 可选预选车辆,不自动安排运输)"""
- validate_create_full(payload)
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan_no = _generate_plan_no(db)
- plan_type = payload.plan_type or _determine_plan_type(payload.supplier_name, payload.destination_name)
- has_vehicle = bool(payload.transport and payload.transport.vehicle_id)
- plan = PlPlan(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_no=plan_no,
- plan_date=payload.plan_date,
- belongs_company=payload.belongs_company,
- plan_type=plan_type,
- follow_status="draft",
- transport_status="pending",
- archive_status="unarchived",
- complete_status="no",
- contract_id=payload.contract_id,
- contract_no=payload.contract_no,
- supplier_id=payload.supplier_id,
- supplier_name=payload.supplier_name,
- destination_id=payload.destination_id,
- destination_name=payload.destination_name,
- customer_id=payload.customer_id,
- customer_name=payload.customer_name,
- contact_name=payload.contact_name,
- contact_phone=payload.contact_phone,
- party_a=payload.party_a,
- party_b=payload.party_b,
- contract_start_date=payload.contract_start_date,
- contract_end_date=payload.contract_end_date,
- delivery_method=payload.delivery_method,
- settlement_method=payload.settlement_method,
- has_freight=payload.has_freight,
- pickup_address=payload.pickup_address,
- pickup_lng=payload.pickup_lng,
- pickup_lat=payload.pickup_lat,
- pickup_time=payload.pickup_time,
- pickup_notes=payload.pickup_notes,
- destination_address=payload.destination_address,
- destination_lng=payload.destination_lng,
- destination_lat=payload.destination_lat,
- delivery_time=payload.delivery_time,
- destination_notes=payload.destination_notes,
- total_amount=calc_plan_total_amount(payload.products or []),
- follow_user_id=payload.follow_user_id or user.id,
- follow_user_name=payload.follow_user_name or user.nick_name,
- remark=payload.remark,
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(plan)
- db.flush()
- plan_products: list[PlPlanProduct] = []
- for idx, prod in enumerate(payload.products or []):
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db,
- tenant_no,
- prod.product_id,
- prod.is_toxic,
- prod.is_explosive,
- )
- prod_payload = prod.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
- pp = build_plan_product(
- tenant_no=tenant_no,
- plan_id=plan.id,
- plan_type=plan_type,
- prod=prod_payload,
- order_num=idx,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- db.add(pp)
- plan_products.append(pp)
- if has_vehicle and payload.transport:
- apply_vehicle_prefill_to_plan(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport_payload=payload.transport,
- products=plan_products,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- db.commit()
- db.refresh(plan)
- result = PlanWithProducts.model_validate(plan)
- result.products = plan_products
- return result
- # =============================================================================
- # 从合同创建计划
- # =============================================================================
- @router.post("/create-from-contract", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
- def create_from_contract(
- payload: CreateFromContractRequest,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanRead:
- """从 CRM 合同发起计划"""
- from app.models.crm_models import CrmContract, CrmContractProduct
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- # 查询合同信息
- contract = (
- db.query(CrmContract)
- .filter(CrmContract.id == payload.contract_id, CrmContract.del_flag == 0)
- .first()
- )
- if not contract:
- raise HTTPException(status_code=404, detail="合同不存在")
- # 生成计划编号
- plan_no = _generate_plan_no(db)
- # 自动判断单据类型
- plan_type = _determine_plan_type(contract.supplier_name, contract.customer_name)
- plan = PlPlan(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_no=plan_no,
- plan_date=payload.plan_date,
- belongs_company=contract.belongs_company or "shanxia",
- plan_type=plan_type,
- follow_status="draft",
- transport_status="pending",
- archive_status="unarchived",
- complete_status="no",
- contract_id=contract.id,
- contract_no=contract.contract_no,
- supplier_id=contract.supplier_id,
- supplier_name=(contract.party_a or contract.supplier_name or "").strip() or None,
- destination_id=contract.customer_id,
- destination_name=(contract.party_b or contract.customer_name or "").strip() or None,
- customer_id=contract.customer_id,
- customer_name=(contract.party_b or contract.customer_name or "").strip() or None,
- contact_name=contract.contact_name,
- contact_phone=contract.contact_phone,
- party_a=contract.party_a,
- party_b=contract.party_b,
- contract_start_date=contract.start_date,
- contract_end_date=contract.end_date,
- delivery_method=contract.delivery_method,
- settlement_method=contract.settlement_method,
- has_freight=1 if contract.settlement_method and "含运" in contract.settlement_method else 0,
- follow_user_id=payload.follow_user_id or user.id,
- follow_user_name=payload.follow_user_name or user.nick_name,
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(plan)
- # 查询合同产品明细
- contract_products = (
- db.query(CrmContractProduct)
- .filter(
- CrmContractProduct.contract_id == contract.id,
- CrmContractProduct.del_flag == 0,
- )
- .all()
- )
- # 添加产品明细
- for idx, cp in enumerate(contract_products):
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db,
- tenant_no,
- cp.product_id,
- 0,
- 0,
- )
- pp = PlPlanProduct(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_id=plan.id,
- product_id=cp.product_id,
- product_no=cp.product_no,
- product_name=cp.product_name,
- model=cp.specification,
- unit=cp.unit or "吨",
- is_explosive=is_explosive,
- is_toxic=is_toxic,
- inbound_estimate_qty=cp.quantity,
- outbound_estimate_qty=cp.quantity,
- order_num=idx,
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- db.add(pp)
- db.commit()
- db.refresh(plan)
- return plan
- # =============================================================================
- # 获取计划详情
- # =============================================================================
- @router.get("/{id}", response_model=PlanWithProducts)
- def get_plan(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanWithProducts:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == id,
- PlPlanProduct.del_flag == 0,
- )
- .order_by(PlPlanProduct.order_num)
- .all()
- )
- enriched_products = []
- for product in products:
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db,
- tenant_no,
- product.product_id,
- product.is_toxic,
- product.is_explosive,
- )
- item = PlanProductRead.model_validate(product)
- if is_toxic != int(product.is_toxic or 0) or is_explosive != int(product.is_explosive or 0):
- item = item.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
- enriched_products.append(item)
- result = PlanWithProducts.model_validate(plan)
- result.products = enriched_products
- return result
- # =============================================================================
- # 更新计划
- # =============================================================================
- @router.put("/{id}", response_model=PlanRead)
- def update_plan(
- id: str,
- payload: PlanUpdate,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanRead:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- # 更新主表字段
- update_data = payload.model_dump(exclude_unset=True, exclude={"products", "transport"})
- for key, value in update_data.items():
- setattr(plan, key, value)
- plan_type = payload.plan_type or plan.plan_type
- # 更新产品明细
- if payload.products is not None:
- existing_products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == id,
- PlPlanProduct.del_flag == 0,
- )
- .all()
- )
- cert_by_product_id = {
- row.product_id: (row.purchase_cert_url, row.transport_cert_url)
- for row in existing_products
- }
- db.query(PlPlanProduct).filter(
- PlPlanProduct.plan_id == id,
- PlPlanProduct.del_flag == 0,
- ).update({"del_flag": 1})
- plan_products: list[PlPlanProduct] = []
- for idx, prod in enumerate(payload.products):
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db,
- tenant_no,
- prod.product_id,
- prod.is_toxic,
- prod.is_explosive,
- )
- purchase_cert = prod.purchase_cert_url
- transport_cert = prod.transport_cert_url
- if not purchase_cert or not transport_cert:
- old_purchase, old_transport = cert_by_product_id.get(prod.product_id, (None, None))
- purchase_cert = purchase_cert or old_purchase
- transport_cert = transport_cert or old_transport
- prod_payload = prod.model_copy(
- update={
- "is_toxic": is_toxic,
- "is_explosive": is_explosive,
- "purchase_cert_url": purchase_cert,
- "transport_cert_url": transport_cert,
- }
- )
- pp = build_plan_product(
- tenant_no=tenant_no,
- plan_id=id,
- plan_type=plan_type,
- prod=prod_payload,
- order_num=idx,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- db.add(pp)
- plan_products.append(pp)
- plan.total_amount = calc_plan_total_amount(payload.products)
- else:
- plan_products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == id,
- PlPlanProduct.del_flag == 0,
- )
- .all()
- )
- # 同步运输预选 / 关联运输单运费
- if payload.transport is not None:
- if payload.transport.vehicle_id:
- sync_transport_prefill_for_plan(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport_payload=payload.transport,
- products=plan_products,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- else:
- clear_draft_transport_for_plan(
- db,
- tenant_no=tenant_no,
- plan=plan,
- user_id=user.id,
- )
- plan.update_by = user.id
- db.commit()
- db.refresh(plan)
- return plan
- @router.patch("/{id}/product-certs", response_model=PlanWithProducts)
- def patch_plan_product_certs(
- id: str,
- payload: PlanProductCertsUpdate,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanWithProducts:
- """更新计划危化品产品购买证/合法使用证明(不触发安排运输)"""
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- _get_plan(db, tenant_no, id, user)
- update_plan_product_certs(
- db,
- tenant_no=tenant_no,
- plan_id=id,
- items=payload.products,
- user_id=user.id,
- )
- plan = _get_plan(db, tenant_no, id, user)
- products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == id,
- PlPlanProduct.del_flag == 0,
- )
- .order_by(PlPlanProduct.order_num)
- .all()
- )
- result = PlanWithProducts.model_validate(plan)
- result.products = products
- return result
- def _build_driver_url(token: str) -> str:
- origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
- return f"{origin}/#/public/pl/transport/{token}"
- @router.get("/{id}/arrange-info", response_model=PlanArrangeInfoRead)
- def get_plan_arrange_info(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanArrangeInfoRead:
- """获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。"""
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- empty = PlanArrangeInfoRead(driver_url=None, license_plate=None, site_records=[], inventory_records=[])
- if plan.follow_status == "cancelled" or plan.transport_status != "arranged" or not plan.transport_id:
- return empty
- transport = (
- db.query(PlTransport)
- .filter(
- PlTransport.id == plan.transport_id,
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- )
- .first()
- )
- if not transport or transport.transport_status == "cancelled":
- return empty
- token = (transport.driver_access_token or "").strip()
- driver_url = _build_driver_url(token) if token else None
- site_rows = (
- db.query(PlTransportSiteLink)
- .filter(
- PlTransportSiteLink.tenant_no == tenant_no,
- PlTransportSiteLink.plan_id == id,
- PlTransportSiteLink.transport_id == transport.id,
- PlTransportSiteLink.del_flag == 0,
- )
- .order_by(PlTransportSiteLink.create_time.asc())
- .all()
- )
- product_name_by_id = {
- item.id: item.product_name or ""
- for item in db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == id,
- PlPlanProduct.del_flag == 0,
- )
- .all()
- }
- site_records: list[TransportSiteLinkRead] = [
- build_transport_site_link_read_from_row(
- row,
- product_name=product_name_by_id.get(row.plan_product_id, row.product or ""),
- )
- for row in site_rows
- ]
- links = (
- db.query(PlTransportDbLink)
- .filter(
- PlTransportDbLink.tenant_no == tenant_no,
- PlTransportDbLink.plan_id == id,
- PlTransportDbLink.transport_id == transport.id,
- PlTransportDbLink.del_flag == 0,
- )
- .all()
- )
- inventory_records: list[TransportInventoryLinkRead] = []
- for link in links:
- product = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.id == link.plan_product_id,
- PlPlanProduct.del_flag == 0,
- )
- .first()
- )
- inventory = (
- db.query(DbInboundOutbound)
- .filter(
- DbInboundOutbound.id == link.db_inventory_id,
- DbInboundOutbound.tenant_no == tenant_no,
- DbInboundOutbound.del_flag == 0,
- )
- .first()
- )
- inventory_records.append(
- build_transport_inventory_link_read(
- db,
- plan_product_id=link.plan_product_id,
- product_name=(product.product_name if product else "") or "",
- db_purchase_plan_id=link.db_purchase_plan_id,
- db_inventory_id=link.db_inventory_id,
- batch_no=(inventory.batch_no if inventory else "") or "",
- plan_no=plan.plan_no or "",
- )
- )
- return PlanArrangeInfoRead(
- driver_url=driver_url,
- license_plate=transport.license_plate or plan.license_plate,
- site_records=site_records,
- inventory_records=inventory_records,
- )
- # =============================================================================
- # 取消计划
- # =============================================================================
- @router.post("/{id}/cancel", response_model=PlanRead)
- def cancel_plan(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanRead:
- """作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。"""
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail="计划已作废")
- if plan.archive_status == "archived":
- raise HTTPException(status_code=400, detail="已归档的计划不能作废")
- 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:
- try:
- revoke_regeneratable_dibang_for_transport(
- db,
- tenant_no=tenant_no,
- transport_id=transport.id,
- user_id=user.id,
- )
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- revoke_site_links_for_transport(
- db,
- tenant_no=tenant_no,
- transport_id=transport.id,
- user_id=user.id,
- )
- transport.transport_status = "cancelled"
- transport.update_by = user.id
- plan.follow_status = "cancelled"
- plan.transport_status = "pending"
- plan.update_by = user.id
- db.commit()
- db.refresh(plan)
- return plan
- # =============================================================================
- # 归档计划
- # =============================================================================
- @router.post("/{id}/archive", response_model=PlanWithProducts)
- def archive_plan(
- id: str,
- payload: Optional[PlanArchiveBody] = Body(default=None),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanWithProducts:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- if plan.follow_status == "cancelled":
- raise HTTPException(status_code=400, detail="已作废的计划不能归档")
- already_archived = plan.archive_status == "archived"
- if payload is None:
- if already_archived:
- raise HTTPException(status_code=400, detail="计划已归档,请提交归档表单以调整内容")
- plan.archive_status = "archived"
- plan.complete_status = "yes"
- plan.follow_status = "completed"
- plan.update_by = user.id
- db.commit()
- return get_plan(id, db, user)
- apply_archive_product_quantities(
- db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id
- )
- if payload.freight is not None:
- apply_archive_freight(
- db, tenant_no=tenant_no, plan=plan, freight=payload.freight, user_id=user.id
- )
- elif int(plan.has_freight or 0) == 1 and not already_archived:
- if not plan.transport_id:
- raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
- if not already_archived:
- plan.archive_status = "archived"
- plan.complete_status = "yes"
- plan.follow_status = "completed"
- plan.update_by = user.id
- record_plan_change(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- change_type="archive_adjust" if already_archived else "archive",
- change_content="调整归档内容" if already_archived else "运输计划归档",
- user_id=user.id,
- user_name=user.name or None,
- dept_id=user.dept_id,
- )
- db.commit()
- return get_plan(id, db, user)
- # =============================================================================
- # 删除计划
- # =============================================================================
- @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
- def delete_plan(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> None:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- assert_row_deletable_by_creator(plan, user_info_from_context(user))
- plan.del_flag = 1
- plan.update_by = user.id
- db.query(PlPlanProduct).filter(
- PlPlanProduct.plan_id == id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
- db.query(PlPlanTransport).filter(
- PlPlanTransport.plan_id == id,
- PlPlanTransport.tenant_no == tenant_no,
- PlPlanTransport.del_flag == 0,
- ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
- db.commit()
- # =============================================================================
- # 辅助函数
- # =============================================================================
- def _get_plan(db: Session, tenant_no: str, plan_id: str, user: DbUserContext) -> PlPlan:
- 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="计划不存在")
- assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
- return plan
|