| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646 |
- """运输计划管理 - 计划 API"""
- import re
- from datetime import date, datetime
- from typing import List, Optional
- from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
- from pydantic import BaseModel, Field
- 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.core.menu_guard import SUPER_ADMIN_ROLE, get_user_menu_codes
- from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
- from app.services.pl_plan_arrange_copy_flags import load_plan_arrange_copy_flags
- from app.core.tenant_context import get_tenant_no
- from app.utils.excel_export import (
- EXPORT_MAX_ROWS,
- ExportColumn,
- ExportValidationError,
- export_rows_to_xlsx_response,
- filter_export_columns,
- )
- 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,
- PlVehicle,
- )
- from app.schemas.pl import PlanProductCreate
- from app.schemas.pl.plan import (
- CreateFromContractRequest,
- DeliverySlipPrintRead,
- PlanArchiveBody,
- PlanArchiveBrandQtyItem,
- PlanContractGapListResponse,
- PlanContractGapRead,
- PlanContractLink,
- PlanCreate,
- PlanCreateFull,
- PlanDriverFeedbackUpdateBody,
- HazardCertReadyBody,
- ErpEnteredBody,
- PlanForceFollowStatusBody,
- PlanInboundSlipTemplateSave,
- PlanDeliverySlipTemplateSave,
- PlanSubpackSlipTemplateSave,
- PlanDeliveryBody,
- PlanLoadBody,
- PlanListQuery,
- PlanListResponse,
- PlanProductCertsUpdate,
- PlanProductRead,
- PlanRead,
- PlanSideContractsUpdateBody,
- PlanSiteFeedbackUpdateBody,
- PlanStopContractsUpdateBody,
- PlanUpdate,
- PlanWithProducts,
- )
- from app.services.pl_plan_contract_gap import (
- list_plan_contract_gaps,
- update_plan_side_contracts,
- update_plan_stop_contracts,
- )
- from app.services.pl_brand_archive import load_brand_qtys_by_plan_product_ids
- from app.services.pl_product_display import (
- load_crm_product_alias_maps,
- resolve_product_display_names,
- )
- from app.schemas.pl.transport import (
- PlanArrangeInfoRead,
- TransportInventoryLinkRead,
- TransportIoOrderBrief,
- TransportSiteLinkRead,
- )
- from app.services.pl_dibang_bridge_service import (
- revoke_regeneratable_dibang_for_transport,
- sync_linked_dibang_dates_for_plan,
- )
- from app.services.pl_inventory_link_read import build_transport_inventory_link_read
- from app.services.pl_io_order_service import get_active_io_order_for_plan, void_io_orders_for_plan
- from app.services.pl_internal_customer import plan_requires_site_link
- from app.services.pl_plan_site_link_service import build_site_records_for_arranged_plan
- from app.services.pl_transport_site_service import revoke_site_links_for_transport
- from app.services.pl_inventory_service import (
- apply_archive_inventory_for_plan,
- apply_archive_inventory_for_plan_stops,
- )
- from app.services.pl_plan_archive_stop_service import (
- apply_archive_stop_quantities,
- sum_delivery_outbound_qty,
- sync_plan_products_from_stop_archive,
- )
- from app.services.pl_plan_archive_export import build_plan_archive_export_rows
- from app.services.pl_plan_price_mask import (
- mask_plan_with_products_prices,
- user_can_archive_plan,
- user_can_view_archive_prices,
- )
- from app.services.pl_plan_list_attachments import load_plan_document_attachments_by_plan_ids
- from app.services.pl_plan_print_service import get_delivery_slip_print_context
- from app.services.pl_plan_stop_service import (
- attach_plan_stops_to_item,
- collect_stop_qty_warnings,
- load_plan_stops_grouped,
- replace_plan_stops,
- sync_plan_legacy_from_stops,
- uses_multi_stop_payload,
- validate_plan_stops_write,
- )
- from app.services.pl_customer_stop_pref_service import safe_upsert_prefs_from_plan_stops
- from app.services.pl_plan_service import (
- apply_archive_brand_qtys_only,
- apply_archive_freight,
- apply_archive_product_quantities,
- apply_archive_stop_feedback_attachments,
- apply_plan_driver_feedback,
- apply_plan_site_feedback,
- apply_vehicle_prefill_to_plan,
- assert_contract_selectable_for_plan,
- apply_plan_product_fields,
- build_plan_product,
- resolve_plan_product_id_for_upsert,
- calc_plan_total_amount,
- clear_draft_transport_for_plan,
- clear_hazard_cert_ready,
- collect_hazard_product_ids,
- determine_plan_type,
- fetch_hazard_flags_map,
- load_plan_contracts_grouped,
- plan_has_hazard_product_exists,
- plan_has_incomplete_hazard_cert_exists,
- record_plan_change,
- replace_plan_contracts,
- resolve_hazard_flags_from_crm,
- set_hazard_cert_ready,
- set_plan_erp_entered,
- sync_transport_prefill_for_plan,
- update_plan_product_certs,
- assert_can_save_as_draft,
- resolve_follow_status_on_save,
- validate_create_full,
- validate_draft_min_fields,
- validate_hazard_cert_ready_for_transport,
- validate_plan_party_contracts,
- )
- router = APIRouter(prefix="/plan", tags=["运输计划-计划管理"])
- _PLAN_NO_SEQ_WIDTH = 3
- def _resolve_list_product_name(
- product: PlPlanProduct,
- *,
- standard_by_id: dict[str, str],
- alias_options_by_id: dict[str, list[str]],
- ) -> str:
- """列表展示用:优先 CRM 标准名(详情仍返回库内 product_name,以便编辑反推别名)。"""
- display_name, _alias = resolve_product_display_names(
- saved_product_name=product.product_name,
- product_id=product.product_id,
- standard_by_id=standard_by_id,
- alias_options_by_id=alias_options_by_id,
- )
- return display_name or (product.product_name or "")
- def _resolve_create_date(value: Optional[date]) -> date:
- return value or date.today()
- def _generate_plan_no(db: Session) -> str:
- """生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)"""
- today = date.today()
- prefix = f"PO{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}"
- _SUBPACK_SLIP_NO_SEQ_WIDTH = 3
- _SUBPACK_SLIP_NO_CANONICAL_PATTERN = re.compile(r"^NO:SXFZ(\d{8})-(\d+)$")
- _SUBPACK_SLIP_NO_BARE_PATTERN = re.compile(r"^SXFZ(\d{8})-(\d+)$")
- _SUBPACK_SLIP_NO_LEGACY_PATTERNS = (
- re.compile(r"^NO:SXFZ-(\d{4})-(\d{2})-(\d{2})-(\d+)$"),
- re.compile(r"^SXFZ-(\d{4})-(\d{2})-(\d{2})-(\d+)$"),
- )
- def _format_subpack_slip_no(day: str, seq: int) -> str:
- return f"NO:SXFZ{day}-{int(seq):0{_SUBPACK_SLIP_NO_SEQ_WIDTH}d}"
- def _normalize_subpack_slip_no(slip_no: str) -> Optional[str]:
- """将历史分装单号统一为 NO:SXFZYYYYMMDD-###"""
- raw = (slip_no or "").strip()
- if not raw:
- return None
- matched = _SUBPACK_SLIP_NO_CANONICAL_PATTERN.match(raw)
- if matched:
- day, seq = matched.groups()
- return _format_subpack_slip_no(day, int(seq))
- matched = _SUBPACK_SLIP_NO_BARE_PATTERN.match(raw)
- if matched:
- day, seq = matched.groups()
- return _format_subpack_slip_no(day, int(seq))
- for pattern in _SUBPACK_SLIP_NO_LEGACY_PATTERNS:
- matched = pattern.match(raw)
- if matched:
- year, month, day, seq = matched.groups()
- return _format_subpack_slip_no(f"{year}{month}{day}", int(seq))
- return None
- def _subpack_slip_no_seq(slip_no: str, day_prefix: str) -> int:
- normalized = _normalize_subpack_slip_no(slip_no)
- if not normalized or not normalized.startswith(day_prefix):
- return 0
- suffix = normalized[len(day_prefix) :]
- return int(suffix) if suffix.isdigit() else 0
- def _ensure_subpack_slip_no(db: Session, tenant_no: str, plan: PlPlan) -> str:
- """分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)"""
- existing = (plan.subpack_slip_no or "").strip()
- if existing:
- normalized = _normalize_subpack_slip_no(existing)
- if normalized:
- if normalized != existing:
- plan.subpack_slip_no = normalized
- return normalized
- today = date.today()
- prefix = f"NO:SXFZ{today.strftime('%Y%m%d')}-"
- rows = (
- db.query(PlPlan.subpack_slip_no)
- .filter(
- PlPlan.tenant_no == tenant_no,
- PlPlan.subpack_slip_no.isnot(None),
- PlPlan.subpack_slip_no != "",
- )
- .all()
- )
- max_seq = 0
- for (slip_no,) in rows:
- max_seq = max(max_seq, _subpack_slip_no_seq(slip_no or "", prefix))
- next_no = f"{prefix}{max_seq + 1:0{_SUBPACK_SLIP_NO_SEQ_WIDTH}d}"
- plan.subpack_slip_no = next_no
- return next_no
- 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)}吨
- 备注:"""
- # =============================================================================
- # 计划列表
- # =============================================================================
- def _plan_product_name_exists(db: Session, tenant_no: str, keyword: str):
- """子查询:计划产品名称模糊匹配。"""
- return (
- db.query(PlPlanProduct.id)
- .filter(
- PlPlanProduct.plan_id == PlPlan.id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- func.lower(PlPlanProduct.product_name).contains(keyword.lower()),
- )
- .exists()
- )
- def _plan_plate_match(keyword: str):
- """车牌号 / 挂车号模糊匹配。"""
- kw = keyword.lower()
- return or_(
- func.lower(PlPlan.license_plate).contains(kw),
- func.lower(PlPlan.trailer_no).contains(kw),
- )
- def _parse_status_csv(value: Optional[str]) -> List[str]:
- if not value:
- return []
- return [item.strip() for item in value.split(",") if item.strip()]
- def _apply_follow_status_filter(query, follow_status: Optional[str]):
- statuses = _parse_status_csv(follow_status)
- if len(statuses) == 1:
- return query.filter(PlPlan.follow_status == statuses[0])
- if len(statuses) > 1:
- return query.filter(PlPlan.follow_status.in_(statuses))
- return query
- def _apply_exclude_follow_status_filter(query, exclude_follow_status: Optional[str]):
- statuses = _parse_status_csv(exclude_follow_status)
- if len(statuses) == 1:
- return query.filter(PlPlan.follow_status != statuses[0])
- if len(statuses) > 1:
- return query.filter(~PlPlan.follow_status.in_(statuses))
- return query
- @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="跟进状态,多个以英文逗号分隔",
- ),
- exclude_follow_status: Optional[str] = Query(
- default=None,
- alias="excludeFollowStatus",
- 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="客户名称"),
- product_name: Optional[str] = Query(default=None, alias="productName", description="产品名称"),
- license_plate: Optional[str] = Query(default=None, alias="licensePlate", description="车牌号"),
- plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom", description="计划日期开始"),
- plan_date_to: Optional[date] = Query(default=None, alias="planDateTo", description="计划日期结束"),
- has_hazard_product: Optional[int] = Query(
- default=None,
- alias="hasHazardProduct",
- description="1=仅含易制毒/易制爆产品的计划",
- ),
- cert_upload_status: Optional[str] = Query(
- default=None,
- alias="certUploadStatus",
- description="incomplete=至少一张必需危化证书未上传",
- ),
- hazard_cert_ready: Optional[int] = Query(
- default=None,
- alias="hazardCertReady",
- description="0=未确认齐备 1=已确认齐备",
- ),
- contract_id: Optional[str] = Query(
- default=None,
- alias="contractId",
- description="CRM合同ID:返回关联该合同的运输计划(含 pl_plan_contract 与遗留主表 contract_id)",
- ),
- page: int = Query(default=1, ge=1),
- page_size: int = Query(default=20, ge=1, le=500, 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 contract_id and str(contract_id).strip():
- from app.services.pl_contract_plan_qty import plan_ids_linked_to_contract
- linked_ids = plan_ids_linked_to_contract(
- db, tenant_no=tenant_no, contract_id=str(contract_id).strip()
- )
- if not linked_ids:
- return PlanListResponse(total=0, list=[])
- query = query.filter(PlPlan.id.in_(linked_ids))
- if q:
- q_kw = q.strip()
- if q_kw:
- q_lower = q_kw.lower()
- query = query.filter(
- or_(
- PlPlan.plan_no.contains(q_kw),
- func.lower(PlPlan.supplier_name).contains(q_lower),
- func.lower(PlPlan.customer_name).contains(q_lower),
- func.lower(PlPlan.destination_name).contains(q_lower),
- _plan_product_name_exists(db, tenant_no, q_kw),
- _plan_plate_match(q_kw),
- )
- )
- if plan_no:
- query = query.filter(PlPlan.plan_no.contains(plan_no))
- if plan_type:
- query = query.filter(PlPlan.plan_type == plan_type)
- query = _apply_follow_status_filter(query, follow_status)
- query = _apply_exclude_follow_status_filter(query, exclude_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 product_name:
- product_name_kw = product_name.strip()
- if product_name_kw:
- query = query.filter(_plan_product_name_exists(db, tenant_no, product_name_kw))
- if license_plate:
- plate_kw = license_plate.strip()
- if plate_kw:
- query = query.filter(_plan_plate_match(plate_kw))
- 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)
- if has_hazard_product == 1:
- query = query.filter(plan_has_hazard_product_exists(tenant_no))
- if (cert_upload_status or "").strip().lower() == "incomplete":
- query = query.filter(plan_has_incomplete_hazard_cert_exists(tenant_no))
- if hazard_cert_ready is not None:
- query = query.filter(PlPlan.hazard_cert_ready == int(hazard_cert_ready))
- 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]] = {}
- all_products: 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)
- product_ids = [product.product_id for product in all_products if product.product_id]
- hazard_map = fetch_hazard_flags_map(
- db,
- tenant_no,
- product_ids,
- )
- standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
- db,
- tenant_no=tenant_no,
- product_ids=product_ids,
- )
- brand_qtys_by_pp = load_brand_qtys_by_plan_product_ids(
- db,
- tenant_no=tenant_no,
- plan_product_ids=[p.id for p in all_products],
- )
- contracts_by_plan = load_plan_contracts_grouped(db, tenant_no, plan_ids)
- arrange_copy_flags = load_plan_arrange_copy_flags(db, tenant_no, plans)
- document_attachments_by_plan = load_plan_document_attachments_by_plan_ids(
- db,
- tenant_no=tenant_no,
- plan_ids=plan_ids,
- )
- stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
- can_price = user_can_view_archive_prices(db, user)
- 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,
- hazard_map=hazard_map,
- )
- item = PlanProductRead.model_validate(product)
- raw_brand_qtys = brand_qtys_by_pp.get(product.id) or None
- brand_qtys = (
- [PlanArchiveBrandQtyItem.model_validate(x) for x in raw_brand_qtys]
- if raw_brand_qtys
- else None
- )
- item = item.model_copy(
- update={
- "is_toxic": is_toxic,
- "is_explosive": is_explosive,
- "brand_qtys": brand_qtys,
- "product_name": _resolve_list_product_name(
- product,
- standard_by_id=standard_by_id,
- alias_options_by_id=alias_options_by_id,
- ),
- }
- )
- enriched_products.append(item)
- plan_item = PlanWithProducts.model_validate(plan)
- plan_item.products = enriched_products
- supplier_contracts, demander_contracts = contracts_by_plan.get(plan.id, ([], []))
- plan_item.supplier_contracts = supplier_contracts
- plan_item.demander_contracts = demander_contracts
- flags = arrange_copy_flags.get(plan.id)
- if flags:
- plan_item.can_copy_to_driver = flags.can_copy_to_driver
- plan_item.can_copy_to_site = flags.can_copy_to_site
- plan_item.can_copy_pound_qrcode = flags.can_copy_pound_qrcode
- 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}(驾)"
- plan_item.document_attachments = document_attachments_by_plan.get(plan.id, [])
- attach_plan_stops_to_item(
- plan_item,
- plan,
- stops_by_plan.get(plan.id, (None, [])),
- )
- plan_item = mask_plan_with_products_prices(plan_item, enabled=not can_price)
- result_list.append(plan_item)
- return PlanListResponse(total=total, list=result_list)
- PLAN_EXPORT_COLUMNS = [
- ExportColumn(title="计划编号", field="planNo"),
- ExportColumn(title="跟进状态", field="followStatus"),
- ExportColumn(title="计划日期", field="planDate"),
- ExportColumn(title="单据类型", field="planType"),
- ExportColumn(title="供方", field="supplierName"),
- ExportColumn(title="需方", field="customerName"),
- ExportColumn(title="车牌号", field="licensePlate"),
- ExportColumn(title="司机", field="driverName"),
- ExportColumn(title="运输状态", field="transportStatus"),
- ExportColumn(title="归档状态", field="archiveStatus"),
- ExportColumn(title="ERP", field="erpEntered"),
- ExportColumn(title="备注", field="remark"),
- ]
- PLAN_ARCHIVE_EXPORT_COLUMNS = [
- ExportColumn(title="序号", field="rowNo"),
- ExportColumn(title="计划编号", field="planNo"),
- ExportColumn(title="计划日期", field="planDate"),
- ExportColumn(title="归档状态", field="archiveStatus"),
- ExportColumn(title="跟进状态", field="followStatus"),
- ExportColumn(title="单据类型", field="planType"),
- ExportColumn(title="段次", field="stopKind"),
- ExportColumn(title="客户", field="stopCustomer"),
- ExportColumn(title="地址", field="stopAddress"),
- ExportColumn(title="段次时间", field="stopTime"),
- ExportColumn(title="产品名称", field="productName"),
- ExportColumn(title="型号/规格", field="model"),
- ExportColumn(title="包装", field="packageSpec"),
- ExportColumn(title="单位", field="unit"),
- ExportColumn(title="计划数量", field="planQty"),
- ExportColumn(title="实际数量", field="actualQty"),
- ExportColumn(title="入库数量", field="inboundQty"),
- ExportColumn(title="出库数量", field="outboundQty"),
- ExportColumn(title="单价(元)", field="unitPrice"),
- ExportColumn(title="金额(元)", field="amount"),
- ExportColumn(title="车牌号", field="licensePlate"),
- ExportColumn(title="运费单位", field="freightType"),
- ExportColumn(title="运费单价", field="freightPrice"),
- ExportColumn(title="运费金额", field="freightAmount"),
- ExportColumn(title="ERP", field="erpEntered"),
- ExportColumn(title="备注", field="remark"),
- ]
- def _load_transports_by_id(db: Session, tenant_no: str, transport_ids: list[str]) -> dict[str, PlTransport]:
- if not transport_ids:
- return {}
- rows = (
- db.query(PlTransport)
- .filter(
- PlTransport.tenant_no == tenant_no,
- PlTransport.del_flag == 0,
- PlTransport.id.in_(transport_ids),
- )
- .all()
- )
- return {row.id: row for row in rows}
- class PlanExportBody(BaseModel):
- q: Optional[str] = None
- plan_no: Optional[str] = Field(default=None, alias="planNo")
- plan_type: Optional[str] = Field(default=None, alias="planType")
- follow_status: Optional[str] = Field(default=None, alias="followStatus")
- exclude_follow_status: Optional[str] = Field(default=None, alias="excludeFollowStatus")
- transport_status: Optional[str] = Field(default=None, alias="transportStatus")
- archive_status: Optional[str] = Field(default=None, alias="archiveStatus")
- customer_name: Optional[str] = Field(default=None, alias="customerName")
- product_name: Optional[str] = Field(default=None, alias="productName")
- license_plate: Optional[str] = Field(default=None, alias="licensePlate")
- plan_date_from: Optional[date] = Field(default=None, alias="planDateFrom")
- plan_date_to: Optional[date] = Field(default=None, alias="planDateTo")
- has_hazard_product: Optional[int] = Field(default=None, alias="hasHazardProduct")
- cert_upload_status: Optional[str] = Field(default=None, alias="certUploadStatus")
- hazard_cert_ready: Optional[int] = Field(default=None, alias="hazardCertReady")
- ids: Optional[List[str]] = None
- columns: Optional[List[str]] = None
- export_format: Optional[str] = Field(default=None, alias="exportFormat", description="archive=运输计划归档专用导出")
- model_config = {"populate_by_name": True}
- @router.post("/export")
- def export_plans(
- body: PlanExportBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- """导出计划列表(当前筛选全量,或 ids 已选)。"""
- try:
- 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 body.q:
- q_kw = body.q.strip()
- if q_kw:
- q_lower = q_kw.lower()
- query = query.filter(
- or_(
- PlPlan.plan_no.contains(q_kw),
- func.lower(PlPlan.supplier_name).contains(q_lower),
- func.lower(PlPlan.customer_name).contains(q_lower),
- func.lower(PlPlan.destination_name).contains(q_lower),
- _plan_product_name_exists(db, tenant_no, q_kw),
- _plan_plate_match(q_kw),
- )
- )
- if body.plan_no:
- query = query.filter(PlPlan.plan_no.contains(body.plan_no))
- if body.plan_type:
- query = query.filter(PlPlan.plan_type == body.plan_type)
- query = _apply_follow_status_filter(query, body.follow_status)
- query = _apply_exclude_follow_status_filter(query, body.exclude_follow_status)
- if body.transport_status:
- query = query.filter(PlPlan.transport_status == body.transport_status)
- if body.archive_status:
- query = query.filter(PlPlan.archive_status == body.archive_status)
- if body.customer_name:
- query = query.filter(func.lower(PlPlan.customer_name).contains(body.customer_name.lower()))
- if body.product_name:
- product_name_kw = body.product_name.strip()
- if product_name_kw:
- query = query.filter(_plan_product_name_exists(db, tenant_no, product_name_kw))
- if body.license_plate:
- plate_kw = body.license_plate.strip()
- if plate_kw:
- query = query.filter(_plan_plate_match(plate_kw))
- if body.plan_date_from:
- query = query.filter(PlPlan.plan_date >= body.plan_date_from)
- if body.plan_date_to:
- query = query.filter(PlPlan.plan_date <= body.plan_date_to)
- if body.has_hazard_product == 1:
- query = query.filter(plan_has_hazard_product_exists(tenant_no))
- if (body.cert_upload_status or "").strip().lower() == "incomplete":
- query = query.filter(plan_has_incomplete_hazard_cert_exists(tenant_no))
- if body.hazard_cert_ready is not None:
- query = query.filter(PlPlan.hazard_cert_ready == int(body.hazard_cert_ready))
- if body.ids:
- query = query.filter(PlPlan.id.in_(body.ids))
- query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
- plans = query.order_by(PlPlan.create_time.desc()).limit(EXPORT_MAX_ROWS + 1).all()
- date_str = datetime.now().strftime("%Y%m%d")
- is_archive_export = (body.export_format or "").strip().lower() == "archive"
- if is_archive_export:
- transport_ids = [plan.transport_id for plan in plans if plan.transport_id]
- transports_by_id = _load_transports_by_id(db, tenant_no, transport_ids)
- items = build_plan_archive_export_rows(db, tenant_no, plans, transports_by_id)
- cols = filter_export_columns(PLAN_ARCHIVE_EXPORT_COLUMNS, body.columns)
- if not user_can_view_archive_prices(db, user):
- cols = [c for c in cols if c.field not in ARCHIVE_PRICE_EXPORT_FIELDS]
- sheet_title = "计划归档"
- filename_stem = f"计划归档_{date_str}"
- else:
- items = []
- for plan in plans:
- data = PlanWithProducts.model_validate(plan).model_dump(by_alias=True)
- if data.get("planDate") is not None:
- data["planDate"] = str(data["planDate"])[:10]
- if data.get("erpEntered") is not None:
- data["erpEntered"] = "是" if int(data["erpEntered"]) else "否"
- items.append(data)
- cols = filter_export_columns(PLAN_EXPORT_COLUMNS, body.columns)
- sheet_title = "计划列表"
- filename_stem = f"计划列表_{date_str}"
- return export_rows_to_xlsx_response(
- db,
- tenant_no=tenant_no,
- sheet_title=sheet_title,
- columns=cols,
- rows=items,
- filename_stem=filename_stem,
- )
- except ExportValidationError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
- @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:
- from app.models.pl_models import PlPlanContract
- 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": {}}
- # 旧单:主表 contract_id;新单:pl_plan_contract 分侧关联(按计划去重计数)
- legacy_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),
- )
- )
- legacy_rows = apply_pl_scope(legacy_rows, PlPlan, user, db, tenant_no).group_by(PlPlan.contract_id).all()
- counts: dict[str, int] = {contract_id: count for contract_id, count in legacy_rows}
- link_q = (
- db.query(PlPlanContract.contract_id, PlPlan.id)
- .join(PlPlan, PlPlan.id == PlPlanContract.plan_id)
- .filter(
- PlPlanContract.tenant_no == tenant_no,
- PlPlanContract.del_flag == 0,
- PlPlanContract.contract_id.in_(ids),
- PlPlan.tenant_no == tenant_no,
- PlPlan.del_flag == 0,
- )
- )
- link_q = apply_pl_scope(link_q, PlPlan, user, db, tenant_no)
- seen: set[tuple[str, str]] = set()
- for contract_id, plan_id in link_q.all():
- key = (contract_id, plan_id)
- if key in seen:
- continue
- seen.add(key)
- # 若旧主表已计过同一计划+合同,不再重复加(新单主表 contract_id 应为空)
- counts[contract_id] = counts.get(contract_id, 0) + 1
- return {"counts": counts}
- @router.get("/contract-product-arrange-qty")
- def get_contract_product_arrange_qty(
- contract_id: str = Query(..., alias="contractId"),
- product_id: str = Query(..., alias="productId"),
- exclude_plan_id: Optional[str] = Query(default=None, alias="excludePlanId"),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> dict:
- """表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。"""
- from app.services.pl_contract_plan_qty import get_contract_product_arrange_qty_detail
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- return get_contract_product_arrange_qty_detail(
- db,
- tenant_no=tenant_no,
- contract_id=contract_id,
- product_id=product_id,
- exclude_plan_id=exclude_plan_id,
- )
- @router.get("/contract-gaps", response_model=PlanContractGapListResponse)
- def list_contract_gaps(
- page: int = Query(default=1, ge=1),
- page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
- plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划编号"),
- gap_type: Optional[str] = Query(
- default=None,
- alias="gapType",
- description="缺口类型:supplier | demander | both",
- ),
- customer_keyword: Optional[str] = Query(
- default=None,
- alias="customerKeyword",
- description="供方或需方名称关键词",
- ),
- plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom"),
- plan_date_to: Optional[date] = Query(default=None, alias="planDateTo"),
- q: Optional[str] = Query(
- default=None,
- description="模糊搜索(计划编号/供方名称/需方名称)",
- ),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanContractGapListResponse:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- total, items = list_plan_contract_gaps(
- db,
- tenant_no=tenant_no,
- user=user,
- page=page,
- page_size=page_size,
- plan_no=plan_no,
- gap_type=gap_type,
- customer_keyword=customer_keyword,
- plan_date_from=plan_date_from,
- plan_date_to=plan_date_to,
- q=q,
- )
- return PlanContractGapListResponse(total=total, list=items)
- CONTRACT_GAP_EXPORT_COLUMNS = [
- ExportColumn(title="计划编号", field="planNo"),
- ExportColumn(title="计划日期", field="planDate"),
- ExportColumn(title="单据类型", field="planType"),
- ExportColumn(title="跟进状态", field="followStatus"),
- ExportColumn(title="供方", field="supplierName"),
- ExportColumn(title="需方", field="customerName"),
- ExportColumn(title="缺口类型", field="gapType"),
- ]
- class ContractGapExportBody(BaseModel):
- plan_no: Optional[str] = Field(default=None, alias="planNo")
- gap_type: Optional[str] = Field(default=None, alias="gapType")
- customer_keyword: Optional[str] = Field(default=None, alias="customerKeyword")
- plan_date_from: Optional[date] = Field(default=None, alias="planDateFrom")
- plan_date_to: Optional[date] = Field(default=None, alias="planDateTo")
- q: Optional[str] = None
- ids: Optional[List[str]] = None
- columns: Optional[List[str]] = None
- model_config = {"populate_by_name": True}
- @router.post("/contract-gaps/export")
- def export_contract_gaps(
- body: ContractGapExportBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- try:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- _, items = list_plan_contract_gaps(
- db,
- tenant_no=tenant_no,
- user=user,
- page=1,
- page_size=EXPORT_MAX_ROWS,
- plan_no=body.plan_no,
- gap_type=body.gap_type,
- customer_keyword=body.customer_keyword,
- plan_date_from=body.plan_date_from,
- plan_date_to=body.plan_date_to,
- q=body.q,
- ids=body.ids,
- max_page_size=EXPORT_MAX_ROWS,
- )
- plan_type_labels = {"direct": "直销单", "inbound": "入库单", "outbound": "出库单"}
- gap_labels = {"supplier": "仅缺供方合同", "demander": "仅缺需方合同", "both": "双侧缺口"}
- rows = []
- for item in items:
- data = item.model_dump(by_alias=True)
- data["planType"] = plan_type_labels.get(str(data.get("planType") or ""), data.get("planType"))
- sides = set(data.get("gapSides") or [])
- if "supplier" in sides and "demander" in sides:
- data["gapType"] = "双侧缺口"
- elif "supplier" in sides:
- data["gapType"] = "仅缺供方合同"
- elif "demander" in sides:
- data["gapType"] = "仅缺需方合同"
- else:
- data["gapType"] = ""
- for k, v in list(data.items()):
- if hasattr(v, "isoformat"):
- data[k] = str(v)[:19]
- rows.append(data)
- cols = filter_export_columns(CONTRACT_GAP_EXPORT_COLUMNS, body.columns)
- date_str = datetime.now().strftime("%Y%m%d")
- return export_rows_to_xlsx_response(
- db, tenant_no=tenant_no, sheet_title="合同完善", columns=cols, rows=rows,
- filename_stem=f"计划合同完善_{date_str}",
- )
- except ExportValidationError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
- @router.put("/{id}/stop-contracts", response_model=PlanContractGapRead)
- def put_plan_stop_contracts(
- id: str,
- payload: PlanStopContractsUpdateBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanContractGapRead:
- """保存某装货/送货段合同关联(合同补全)。"""
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- result = update_plan_stop_contracts(
- db,
- tenant_no=tenant_no,
- plan=plan,
- stop_type=payload.stop_type,
- stop_id=payload.stop_id,
- contracts=payload.contracts,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- plan.update_by = user.id
- db.commit()
- return result
- @router.put("/{id}/contracts/{side}", response_model=PlanContractGapRead)
- def put_plan_side_contracts(
- id: str,
- side: str,
- payload: PlanSideContractsUpdateBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanContractGapRead:
- """单侧保存计划合同关联(合同补全)。"""
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- result = update_plan_side_contracts(
- db,
- tenant_no=tenant_no,
- plan=plan,
- side=side,
- contracts=payload.contracts,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- plan.update_by = user.id
- db.commit()
- return result
- # =============================================================================
- # 创建计划
- # =============================================================================
- @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 or "").strip()
- plan = PlPlan(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_no=plan_no,
- create_date=_resolve_create_date(payload.create_date),
- plan_date=payload.plan_date,
- belongs_company=payload.belongs_company,
- plan_type=plan_type,
- follow_status="pending_arrange",
- 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_contact=payload.pickup_contact,
- 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,
- delivery_contact=payload.delivery_contact,
- 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:
- """创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)"""
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal"
- is_draft = save_mode == "draft"
- multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries)
- qty_warnings: list[str] = []
- load_body = None
- delivery_bodies: list = []
- supplier_contracts: list[PlanContractLink] = []
- demander_contracts: list[PlanContractLink] = []
- if is_draft:
- validate_draft_min_fields(
- belongs_company=payload.belongs_company,
- create_date=payload.create_date,
- )
- if multi_stop and payload.load is not None:
- try:
- load_body, delivery_bodies = validate_plan_stops_write(
- db,
- tenant_no,
- payload.load,
- payload.deliveries,
- payload.products,
- )
- qty_warnings = collect_stop_qty_warnings(
- payload.products, load_body, delivery_bodies
- )
- except HTTPException:
- load_body, delivery_bodies = None, []
- multi_stop = False
- elif multi_stop:
- load_body, delivery_bodies = validate_plan_stops_write(
- db,
- tenant_no,
- payload.load,
- payload.deliveries,
- payload.products,
- )
- qty_warnings = collect_stop_qty_warnings(payload.products, load_body, delivery_bodies)
- else:
- validate_create_full(payload)
- supplier_contracts, demander_contracts = validate_plan_party_contracts(
- db,
- tenant_no,
- supplier_id=payload.supplier_id,
- supplier_name=payload.supplier_name,
- customer_id=payload.customer_id,
- customer_name=payload.customer_name,
- supplier_contracts=payload.supplier_contracts,
- demander_contracts=payload.demander_contracts,
- require_products=payload.products,
- )
- load_body = None
- delivery_bodies = []
- plan_no = _generate_plan_no(db)
- # 单据类型由前端显式选择;未选则存空,不再自动推断为直销单
- plan_type = (payload.plan_type or "").strip()
- has_vehicle = bool(payload.transport and payload.transport.vehicle_id)
- demander_name = (payload.customer_name or "").strip() or None
- plan = PlPlan(
- id=generate_uuid(),
- tenant_no=tenant_no,
- plan_no=plan_no,
- create_date=_resolve_create_date(payload.create_date),
- plan_date=payload.plan_date,
- belongs_company=payload.belongs_company,
- plan_type=plan_type,
- follow_status=resolve_follow_status_on_save(
- save_mode=save_mode, current=None, is_create=True
- ),
- transport_status="pending",
- archive_status="unarchived",
- complete_status="no",
- contract_id=None,
- contract_no=None,
- supplier_id=payload.supplier_id,
- supplier_name=payload.supplier_name,
- destination_id=payload.customer_id or payload.destination_id,
- destination_name=demander_name or 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,
- freight_remark=payload.freight_remark,
- pickup_address=payload.pickup_address,
- pickup_lng=payload.pickup_lng,
- pickup_lat=payload.pickup_lat,
- pickup_time=payload.pickup_time,
- pickup_contact=payload.pickup_contact,
- 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,
- delivery_contact=payload.delivery_contact,
- 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()
- if multi_stop and load_body is not None:
- sync_plan_legacy_from_stops(plan, load_body, delivery_bodies)
- 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_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 multi_stop and load_body is not None:
- replace_plan_stops(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- load=load_body,
- deliveries=delivery_bodies,
- user_id=user.id,
- dept_id=user.dept_id,
- plan_products=plan_products,
- )
- safe_upsert_prefs_from_plan_stops(
- db,
- tenant_no=tenant_no,
- load=load_body,
- deliveries=delivery_bodies,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- else:
- replace_plan_contracts(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- supplier_contracts=supplier_contracts,
- demander_contracts=demander_contracts,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- 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
- result.supplier_contracts = supplier_contracts
- result.demander_contracts = demander_contracts
- result.warnings = qty_warnings
- stops = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(plan.id, (None, []))
- attach_plan_stops_to_item(result, plan, stops)
- 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="合同不存在")
- assert_contract_selectable_for_plan(db, contract.id, tenant_no)
- # 生成计划编号
- 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,
- create_date=_resolve_create_date(payload.create_date),
- plan_date=payload.plan_date,
- belongs_company=contract.belongs_company or "shanxia",
- plan_type=plan_type,
- follow_status="pending_arrange",
- 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,
- # 客户名称严格跟合同「客户名称」;目的地名称与客户保持一致(不再取需方可章名 party_b)
- destination_name=(contract.customer_name or "").strip() or None,
- customer_id=contract.customer_id,
- customer_name=(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,
- )
- 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,
- brand_mode=cp.brand_mode,
- brand_id=cp.brand_id,
- brand_name=cp.brand_name,
- model=cp.model,
- 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()
- )
- hazard_map = fetch_hazard_flags_map(
- db,
- tenant_no,
- [product.product_id for product in products if product.product_id],
- )
- brand_qtys_by_pp = load_brand_qtys_by_plan_product_ids(
- db,
- tenant_no=tenant_no,
- plan_product_ids=[p.id for p in products],
- )
- enriched_products = []
- for product in products:
- is_toxic, is_explosive = resolve_hazard_flags_from_crm(
- db,
- tenant_no,
- product.product_id,
- hazard_map=hazard_map,
- )
- item = PlanProductRead.model_validate(product)
- raw_brand_qtys = brand_qtys_by_pp.get(product.id) or None
- brand_qtys = (
- [PlanArchiveBrandQtyItem.model_validate(x) for x in raw_brand_qtys]
- if raw_brand_qtys
- else None
- )
- item = item.model_copy(
- update={
- "is_toxic": is_toxic,
- "is_explosive": is_explosive,
- "brand_qtys": brand_qtys,
- }
- )
- enriched_products.append(item)
- result = PlanWithProducts.model_validate(plan)
- result.products = enriched_products
- supplier_contracts, demander_contracts = load_plan_contracts_grouped(
- db, tenant_no, [id]
- ).get(id, ([], []))
- result.supplier_contracts = supplier_contracts
- result.demander_contracts = demander_contracts
- stops = load_plan_stops_grouped(db, tenant_no, [id]).get(id, (None, []))
- attach_plan_stops_to_item(result, plan, stops)
- can_price = user_can_view_archive_prices(db, user)
- return mask_plan_with_products_prices(result, enabled=not can_price)
- # =============================================================================
- # 更新计划
- # =============================================================================
- @router.put("/{id}", response_model=PlanWithProducts)
- def update_plan(
- id: str,
- payload: PlanUpdate,
- 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="已作废的计划不能修改")
- save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal"
- is_draft = save_mode == "draft"
- if is_draft:
- assert_can_save_as_draft(plan.follow_status)
- validate_draft_min_fields(
- belongs_company=(
- payload.belongs_company
- if payload.belongs_company is not None
- else plan.belongs_company
- ),
- create_date=(
- payload.create_date if payload.create_date is not None else plan.create_date
- ),
- )
- multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries)
- qty_warnings: list[str] = []
- load_body: Optional[PlanLoadBody] = None
- delivery_bodies: list[PlanDeliveryBody] = []
- # 更新主表字段(跟进状态仅由业务动作 / saveMode 决议维护,忽略客户端 follow_status)
- update_data = payload.model_dump(
- exclude_unset=True,
- exclude={
- "products",
- "transport",
- "follow_status",
- "save_mode",
- "supplier_contracts",
- "demander_contracts",
- "load",
- "deliveries",
- },
- )
- for key, value in update_data.items():
- setattr(plan, key, value)
- # 新模型:单一合同字段不再作为写入源
- if "supplier_contracts" in payload.model_fields_set or "demander_contracts" in payload.model_fields_set:
- plan.contract_id = None
- plan.contract_no = None
- if payload.customer_id is not None or payload.customer_name is not None:
- plan.destination_id = plan.customer_id
- plan.destination_name = plan.customer_name
- supplier_id = plan.supplier_id
- supplier_name = plan.supplier_name
- customer_id = plan.customer_id
- customer_name = plan.customer_name
- if is_draft:
- supplier_contracts: list[PlanContractLink] = []
- demander_contracts: list[PlanContractLink] = []
- if multi_stop and payload.load is not None:
- try:
- load_body, delivery_bodies = validate_plan_stops_write(
- db,
- tenant_no,
- payload.load,
- payload.deliveries,
- payload.products,
- )
- products_for_warn = payload.products if payload.products is not None else None
- qty_warnings = collect_stop_qty_warnings(
- products_for_warn, load_body, delivery_bodies
- )
- sync_plan_legacy_from_stops(plan, load_body, delivery_bodies)
- except HTTPException:
- # 跳过段更新,保留库内原段
- load_body, delivery_bodies = None, []
- multi_stop = False
- if not multi_stop:
- supplier_contracts, demander_contracts = load_plan_contracts_grouped(
- db, tenant_no, [id]
- ).get(id, ([], []))
- elif multi_stop:
- load_body, delivery_bodies = validate_plan_stops_write(
- db,
- tenant_no,
- payload.load,
- payload.deliveries,
- payload.products,
- )
- products_for_warn = payload.products if payload.products is not None else None
- qty_warnings = collect_stop_qty_warnings(products_for_warn, load_body, delivery_bodies)
- sync_plan_legacy_from_stops(plan, load_body, delivery_bodies)
- supplier_contracts = []
- demander_contracts = []
- else:
- supplier_contracts_in = (
- payload.supplier_contracts
- if "supplier_contracts" in payload.model_fields_set
- else load_plan_contracts_grouped(db, tenant_no, [id]).get(id, ([], []))[0]
- )
- demander_contracts_in = (
- payload.demander_contracts
- if "demander_contracts" in payload.model_fields_set
- else load_plan_contracts_grouped(db, tenant_no, [id]).get(id, ([], []))[1]
- )
- # 编辑保存若带了产品或任一侧合同字段,按新规则完整校验供方/需方
- if (
- payload.products is not None
- or "supplier_contracts" in payload.model_fields_set
- or "demander_contracts" in payload.model_fields_set
- or payload.supplier_id is not None
- or payload.customer_id is not None
- ):
- supplier_contracts, demander_contracts = validate_plan_party_contracts(
- db,
- tenant_no,
- supplier_id=supplier_id,
- supplier_name=supplier_name,
- customer_id=customer_id,
- customer_name=customer_name,
- supplier_contracts=supplier_contracts_in,
- demander_contracts=demander_contracts_in,
- require_products=payload.products if payload.products is not None else None,
- )
- replace_plan_contracts(
- db,
- tenant_no=tenant_no,
- plan_id=id,
- supplier_contracts=supplier_contracts,
- demander_contracts=demander_contracts,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- else:
- supplier_contracts, demander_contracts = load_plan_contracts_grouped(db, tenant_no, [id]).get(
- id, ([], [])
- )
- plan_type = payload.plan_type or plan.plan_type
- # 更新产品明细(按 id upsert,保持 pl_plan_product.id 稳定)
- if payload.products is not None:
- existing_all = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == id,
- PlPlanProduct.tenant_no == tenant_no,
- )
- .all()
- )
- existing_all_by_id = {row.id: row for row in existing_all}
- existing_products = [row for row in existing_all if row.del_flag == 0]
- old_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, existing_products)
- cert_by_product_id = {
- row.product_id: (
- row.purchase_cert_url,
- row.transport_cert_url,
- row.legal_use_cert_url,
- )
- for row in existing_products
- }
- kept_ids: set[str] = set()
- 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,
- )
- purchase_cert = prod.purchase_cert_url
- transport_cert = prod.transport_cert_url
- legal_use_cert = prod.legal_use_cert_url
- old_certs = cert_by_product_id.get(prod.product_id, (None, None, None))
- old_purchase, old_transport, old_legal_use = old_certs
- purchase_cert = purchase_cert or old_purchase
- transport_cert = transport_cert or old_transport
- legal_use_cert = legal_use_cert or old_legal_use
- prod_payload = prod.model_copy(
- update={
- "is_toxic": is_toxic,
- "is_explosive": is_explosive,
- "purchase_cert_url": purchase_cert,
- "transport_cert_url": transport_cert,
- "legal_use_cert_url": legal_use_cert,
- }
- )
- row_id, existing = resolve_plan_product_id_for_upsert(
- plan_id=id,
- payload_id=prod.id,
- existing_by_id=existing_all_by_id,
- )
- if existing is not None:
- apply_plan_product_fields(
- existing,
- plan_type=plan_type,
- prod=prod_payload,
- order_num=idx,
- user_id=user.id,
- )
- kept_ids.add(existing.id)
- plan_products.append(existing)
- else:
- 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,
- product_row_id=row_id,
- )
- db.add(pp)
- kept_ids.add(pp.id)
- plan_products.append(pp)
- for row in existing_products:
- if row.id not in kept_ids:
- row.del_flag = 1
- row.update_by = user.id
- new_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, payload.products)
- if old_hazard_product_ids != new_hazard_product_ids:
- clear_hazard_cert_ready(plan)
- 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,
- )
- if multi_stop and load_body is not None:
- replace_plan_stops(
- db,
- tenant_no=tenant_no,
- plan_id=id,
- load=load_body,
- deliveries=delivery_bodies,
- user_id=user.id,
- dept_id=user.dept_id,
- plan_products=plan_products,
- )
- safe_upsert_prefs_from_plan_stops(
- db,
- tenant_no=tenant_no,
- load=load_body,
- deliveries=delivery_bodies,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- plan.update_by = user.id
- plan.follow_status = resolve_follow_status_on_save(
- save_mode=save_mode,
- current=plan.follow_status,
- is_create=False,
- )
- dibang_date_warnings = sync_linked_dibang_dates_for_plan(
- db,
- tenant_no=tenant_no,
- plan=plan,
- user_id=user.id,
- dept_id=user.dept_id or "",
- )
- db.commit()
- db.refresh(plan)
- result = PlanWithProducts.model_validate(plan)
- result.products = plan_products
- result.supplier_contracts = supplier_contracts
- result.demander_contracts = demander_contracts
- result.warnings = [*qty_warnings, *dibang_date_warnings]
- stops = load_plan_stops_grouped(db, tenant_no, [id]).get(id, (None, []))
- attach_plan_stops_to_item(result, plan, stops)
- return result
- @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
- @router.patch("/{id}/hazard-cert-ready", response_model=PlanWithProducts)
- def patch_plan_hazard_cert_ready(
- id: str,
- payload: HazardCertReadyBody,
- 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)
- set_hazard_cert_ready(
- db,
- tenant_no=tenant_no,
- plan_id=id,
- ready=payload.ready,
- 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
- @router.patch("/{id}/erp-entered", response_model=PlanWithProducts)
- def patch_plan_erp_entered(
- id: str,
- payload: ErpEnteredBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanWithProducts:
- """标记或取消运输计划已录入 ERP。"""
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- set_plan_erp_entered(
- db,
- tenant_no=tenant_no,
- plan_id=id,
- entered=payload.entered,
- 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
- result.document_attachments = load_plan_document_attachments_by_plan_ids(
- db,
- tenant_no=tenant_no,
- plan_ids=[id],
- ).get(id, [])
- 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=[],
- requires_site_link=False,
- )
- 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
- requires_site_link = plan_requires_site_link(db, tenant_no=tenant_no, plan=plan)
- site_records = build_site_records_for_arranged_plan(
- db,
- tenant_no=tenant_no,
- plan=plan,
- transport=transport,
- )
- 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 "",
- stop_type=link.stop_type,
- stop_id=link.stop_id,
- )
- )
- io_order_brief = None
- active_io = get_active_io_order_for_plan(db, tenant_no, plan)
- if active_io:
- kind, header = active_io
- from app.services.pl_io_order_service import build_io_order_site_url
- token = (header.site_access_token or "").strip()
- io_order_brief = TransportIoOrderBrief(
- kind=kind,
- order_id=header.id,
- order_no=header.order_no,
- site_access_token=token or None,
- site_url=build_io_order_site_url(kind, token) if token else None,
- status=header.status,
- )
- return PlanArrangeInfoRead(
- driver_url=driver_url,
- license_plate=transport.license_plate or plan.license_plate,
- site_records=site_records,
- inventory_records=inventory_records,
- io_order=io_order_brief,
- requires_site_link=requires_site_link,
- )
- # =============================================================================
- # 跟进状态:等通知 / 取消等通知
- # =============================================================================
- @router.post("/{id}/wait-notice", response_model=PlanRead)
- def mark_plan_wait_notice(
- 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.follow_status not in ("pending_arrange", "drafting"):
- raise HTTPException(status_code=400, detail="仅待安排或草稿中状态可标记为等通知")
- plan.follow_status = "wait_notice"
- plan.update_by = user.id
- db.commit()
- db.refresh(plan)
- return plan
- @router.post("/{id}/cancel-wait-notice", response_model=PlanRead)
- def cancel_plan_wait_notice(
- 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 != "wait_notice":
- raise HTTPException(status_code=400, detail="仅等通知状态可取消等通知")
- if plan.archive_status == "archived":
- raise HTTPException(status_code=400, detail="已归档的计划不能取消等通知")
- plan.follow_status = "pending_arrange"
- plan.update_by = user.id
- db.commit()
- db.refresh(plan)
- return plan
- FORCE_FOLLOW_STATUS_PERM = "pl:plan:force-follow-status"
- ARCHIVE_PRICE_EXPORT_FIELDS = frozenset(
- {"unitPrice", "amount", "freightPrice", "freightAmount"}
- )
- ALLOWED_FORCE_FOLLOW_STATUSES = frozenset(
- {"drafting", "wait_notice", "pending_arrange", "arranged", "completed", "cancelled"}
- )
- @router.post("/{id}/force-follow-status", response_model=PlanRead)
- def force_plan_follow_status(
- id: str,
- payload: PlanForceFollowStatusBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanRead:
- """强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。"""
- if user.role != SUPER_ADMIN_ROLE:
- codes = get_user_menu_codes(db, user.id)
- if FORCE_FOLLOW_STATUS_PERM not in codes:
- raise HTTPException(status_code=403, detail="无强制修改跟进状态权限")
- next_status = (payload.follow_status or "").strip()
- if next_status not in ALLOWED_FORCE_FOLLOW_STATUSES:
- raise HTTPException(
- status_code=400,
- detail="跟进状态仅支持:草稿中/等通知/待安排/已安排/已完成/已取消",
- )
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- plan.follow_status = next_status
- plan.update_by = user.id
- db.commit()
- db.refresh(plan)
- return plan
- # =============================================================================
- # 取消计划
- # =============================================================================
- @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
- void_io_orders_for_plan(
- db, tenant_no=tenant_no, plan_id=plan.id, user_id=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-draft", response_model=PlanWithProducts)
- def archive_plan_draft(
- id: str,
- payload: PlanArchiveBody = Body(...),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> PlanWithProducts:
- """暂存归档填写内容:不改库存、不标已归档。"""
- if not user_can_archive_plan(db, user):
- raise HTTPException(status_code=403, detail="无运输计划归档操作权限")
- 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="已归档的计划请使用调整保存,不能暂存")
- use_stop_archive = payload.load is not None and payload.deliveries is not None
- if use_stop_archive:
- apply_archive_stop_quantities(
- db,
- tenant_no=tenant_no,
- plan=plan,
- load_block=payload.load,
- delivery_blocks=payload.deliveries or [],
- user_id=user.id,
- strict=False,
- )
- sync_plan_products_from_stop_archive(
- db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id
- )
- if payload.products:
- apply_archive_brand_qtys_only(
- db,
- tenant_no=tenant_no,
- plan=plan,
- items=payload.products,
- user_id=user.id,
- )
- elif payload.products:
- 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,
- strict=False,
- )
- if payload.feedbacks is not None and plan.transport_id:
- apply_archive_stop_feedback_attachments(
- db,
- tenant_no=tenant_no,
- plan=plan,
- feedbacks=payload.feedbacks,
- user_id=user.id,
- dept_id=user.dept_id or "",
- )
- plan.archive_status = "draft"
- plan.update_by = user.id
- record_plan_change(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- change_type="archive_draft",
- change_content="暂存归档内容",
- user_id=user.id,
- user_name=user.name or None,
- dept_id=user.dept_id,
- )
- db.commit()
- return get_plan(id, db, user)
- @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:
- if not user_can_archive_plan(db, user):
- raise HTTPException(status_code=403, detail="无运输计划归档操作权限")
- 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 not already_archived:
- products = (
- db.query(PlPlanProduct)
- .filter(
- PlPlanProduct.plan_id == plan.id,
- PlPlanProduct.tenant_no == tenant_no,
- PlPlanProduct.del_flag == 0,
- )
- .order_by(PlPlanProduct.order_num)
- .all()
- )
- validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
- 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)
- use_stop_archive = payload.load is not None and payload.deliveries is not None
- if use_stop_archive:
- if not payload.deliveries:
- raise HTTPException(status_code=400, detail="请填写全部送货段归档")
- apply_archive_stop_quantities(
- db,
- tenant_no=tenant_no,
- plan=plan,
- load_block=payload.load,
- delivery_blocks=payload.deliveries,
- user_id=user.id,
- )
- sync_plan_products_from_stop_archive(
- db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id
- )
- if payload.products:
- apply_archive_brand_qtys_only(
- db,
- tenant_no=tenant_no,
- plan=plan,
- items=payload.products,
- user_id=user.id,
- )
- apply_archive_inventory_for_plan_stops(
- db,
- tenant_no=tenant_no,
- plan=plan,
- user_id=user.id,
- dept_id=user.dept_id or "",
- )
- else:
- if not payload.products:
- raise HTTPException(status_code=400, detail="请提交产品归档明细")
- apply_archive_product_quantities(
- db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id
- )
- apply_archive_inventory_for_plan(
- db,
- tenant_no=tenant_no,
- plan=plan,
- user_id=user.id,
- dept_id=user.dept_id or "",
- )
- 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 payload.feedbacks is not None and plan.transport_id:
- apply_archive_stop_feedback_attachments(
- db,
- tenant_no=tenant_no,
- plan=plan,
- feedbacks=payload.feedbacks,
- user_id=user.id,
- dept_id=user.dept_id or "",
- )
- 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.patch("/{id}/driver-feedback", response_model=PlanWithProducts)
- def update_plan_driver_feedback(
- id: str,
- payload: PlanDriverFeedbackUpdateBody,
- 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)
- apply_plan_driver_feedback(
- db,
- tenant_no=tenant_no,
- plan=plan,
- payload=payload,
- user_id=user.id,
- )
- record_plan_change(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- change_type="feedback_adjust",
- change_content="归档人员更新司机回传信息",
- user_id=user.id,
- user_name=user.name or None,
- dept_id=user.dept_id,
- )
- plan.update_by = user.id
- db.commit()
- return get_plan(id, db, user)
- @router.patch("/{id}/site-feedback/{link_id}", response_model=PlanWithProducts)
- def update_plan_site_feedback(
- id: str,
- link_id: str,
- payload: PlanSiteFeedbackUpdateBody,
- 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)
- link = apply_plan_site_feedback(
- db,
- tenant_no=tenant_no,
- plan=plan,
- link_id=link_id,
- payload=payload,
- user_id=user.id,
- )
- product_label = (link.product or "现场").strip()
- record_plan_change(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- change_type="feedback_adjust",
- change_content=f"归档人员更新现场回传信息({product_label})",
- user_id=user.id,
- user_name=user.name or None,
- dept_id=user.dept_id,
- )
- plan.update_by = user.id
- db.commit()
- return get_plan(id, db, user)
- # =============================================================================
- # 入库单打印模板
- # =============================================================================
- @router.get("/{id}/inbound-slip-template")
- def get_inbound_slip_template(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> str:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- return plan.slip_template_content or ""
- @router.put("/{id}/inbound-slip-template", status_code=status.HTTP_204_NO_CONTENT)
- def save_inbound_slip_template(
- id: str,
- payload: PlanInboundSlipTemplateSave,
- 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)
- if plan.plan_type != "inbound":
- raise HTTPException(status_code=400, detail="仅入库单计划支持保存入库单打印模板")
- if plan.transport_status != "arranged":
- raise HTTPException(status_code=400, detail="请先完成运输安排后再保存入库单打印模板")
- plan.slip_template_content = payload.slip_template_content or ""
- plan.update_by = user.id
- db.commit()
- # =============================================================================
- # 送(提)货单打印模板
- # =============================================================================
- @router.get("/{id}/print-delivery-slip", response_model=DeliverySlipPrintRead)
- def get_print_delivery_slip(
- id: str,
- customerId: Optional[str] = Query(default=None, description="送货客户ID(多客户时必填)"),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- """按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。"""
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- plan_date = plan.plan_date.isoformat() if plan.plan_date else None
- try:
- return get_delivery_slip_print_context(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- plan_no=plan.plan_no or "",
- plan_date=plan_date,
- customer_id=customerId,
- )
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- @router.get("/{id}/delivery-slip-template")
- def get_delivery_slip_template(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> str:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- return plan.delivery_slip_template_content or ""
- @router.put("/{id}/delivery-slip-template", status_code=status.HTTP_204_NO_CONTENT)
- def save_delivery_slip_template(
- id: str,
- payload: PlanDeliverySlipTemplateSave,
- 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)
- if plan.plan_type == "inbound":
- raise HTTPException(status_code=400, detail="入库单计划请使用入库单打印模板")
- if plan.transport_status != "arranged":
- raise HTTPException(status_code=400, detail="请先完成运输安排后再保存送(提)货单打印模板")
- plan.delivery_slip_template_content = payload.delivery_slip_template_content or ""
- plan.update_by = user.id
- db.commit()
- # =============================================================================
- # 分装单打印模板
- # =============================================================================
- @router.get("/{id}/subpack-slip-template")
- def get_subpack_slip_template(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> str:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- return plan.subpack_slip_template_content or ""
- @router.put("/{id}/subpack-slip-template", status_code=status.HTTP_204_NO_CONTENT)
- def save_subpack_slip_template(
- id: str,
- payload: PlanSubpackSlipTemplateSave,
- 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)
- if plan.plan_type == "inbound":
- raise HTTPException(status_code=400, detail="入库单计划请使用入库单打印模板")
- plan.subpack_slip_template_content = payload.subpack_slip_template_content or ""
- plan.update_by = user.id
- db.commit()
- @router.post("/{id}/subpack-slip-no")
- def ensure_subpack_slip_no(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> str:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = _get_plan(db, tenant_no, id, user)
- if plan.plan_type == "inbound":
- raise HTTPException(status_code=400, detail="入库单计划不支持分装单号")
- slip_no = _ensure_subpack_slip_no(db, tenant_no, plan)
- plan.update_by = user.id
- db.commit()
- return slip_no
- # =============================================================================
- # 删除计划
- # =============================================================================
- @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)
- if plan.follow_status not in ("pending_arrange", "wait_notice", "drafting"):
- raise HTTPException(status_code=400, detail="只能删除待安排、草稿中或等通知且未安排运输的计划")
- if plan.transport_status != "pending":
- raise HTTPException(status_code=400, detail="已安排运输的计划不能删除")
- if plan.archive_status == "archived":
- raise HTTPException(status_code=400, detail="已归档的计划不能删除")
- 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
|