plan.py 153 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060
  1. """运输计划管理 - 计划 API"""
  2. import re
  3. from collections import defaultdict
  4. from datetime import date, datetime
  5. from typing import Annotated, Any, List, Optional, Sequence
  6. from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
  7. from pydantic import BaseModel, ConfigDict, Field
  8. from sqlalchemy import func, or_
  9. from sqlalchemy.orm import Session
  10. from app.core.config import settings
  11. from app.services.pl_mobile_stop_meta import (
  12. validate_mobile_meta_request,
  13. fold_mobile_selection_into_web_fields,
  14. attach_web_selection_as_mobile_meta,
  15. )
  16. from app.services.pl_mobile_product_identity import validate_mobile_product_links
  17. from app.core.database import get_db
  18. from app.core.db_auth import DbUserContext, get_db_current_user
  19. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  20. from app.core.menu_guard import SUPER_ADMIN_ROLE, get_user_menu_codes
  21. from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
  22. from app.services.pl.list_sort import order_plan, parse_sort_params
  23. from app.services.pl_plan_arrange_copy_flags import load_plan_arrange_copy_flags
  24. from app.services.pl_plan_execution_lock import (
  25. apply_force_void_downstream,
  26. collect_force_void_impact,
  27. execution_lock_message,
  28. load_plan_execution_locks,
  29. plan_execution_lock_reason,
  30. )
  31. from app.core.tenant_context import get_tenant_no
  32. from app.utils.excel_export import (
  33. EXPORT_MAX_ROWS,
  34. ExportColumn,
  35. ExportValidationError,
  36. export_rows_to_xlsx_response,
  37. filter_export_columns,
  38. )
  39. from app.core.utils import generate_uuid
  40. from app.models.crm_models import CrmContract, CrmContractProduct, CrmProduct
  41. from app.models.db_models import DbInboundOutbound
  42. from app.models.pl_models import (
  43. PlCarrier,
  44. PlDriver,
  45. PlPlan,
  46. PlPlanContract,
  47. PlPlanDelivery,
  48. PlPlanLoad,
  49. PlPlanProduct,
  50. PlPlanTransport,
  51. PlTransport,
  52. PlTransportDbLink,
  53. PlVehicle,
  54. )
  55. from app.schemas.pl import PlanProductCreate
  56. from app.schemas.pl.cancel_reason import CancelReasonBody, normalize_optional_reason
  57. from app.schemas.pl.plan import (
  58. CreateFromContractRequest,
  59. DeliverySlipPrintRead,
  60. PlanArchiveBody,
  61. PlanArchiveBrandQtyItem,
  62. PlanContractGapListResponse,
  63. PlanContractGapRead,
  64. PlanContractLink,
  65. PlanCreate,
  66. PlanCreateFull,
  67. PlanDriverFeedbackUpdateBody,
  68. HazardCertReadyBody,
  69. ErpEnteredBody,
  70. PlanForceFollowStatusBody,
  71. PlanForceVoidPreview,
  72. PlanInboundSlipTemplateSave,
  73. PlanDeliverySlipTemplateSave,
  74. PlanSubpackSlipTemplateSave,
  75. PlanDeliveryBody,
  76. PlanLoadBody,
  77. PlanListQuery,
  78. PlanListResponse,
  79. PlanProductCertsUpdate,
  80. PlanProductRead,
  81. PlanRead,
  82. PlanSideContractsUpdateBody,
  83. PlanSiteFeedbackUpdateBody,
  84. PlanStopContractsUpdateBody,
  85. PlanUpdate,
  86. PlanWithProducts,
  87. )
  88. from app.services.pl.plan_column_facet_context import strip_plan_facet_self_params
  89. from app.services.pl_plan_contract_gap import (
  90. apply_contract_gap_keyword_filter,
  91. apply_contract_gap_type_filter,
  92. list_plan_contract_gaps,
  93. plan_has_contract_gap_exists,
  94. stop_contract_gap,
  95. update_plan_side_contracts,
  96. update_plan_stop_contracts,
  97. )
  98. from app.services.pl_brand_archive import (
  99. expand_export_rows_for_plan_brand,
  100. load_brand_qtys_by_plan_product_ids,
  101. load_brand_qtys_by_stop_product_ids,
  102. )
  103. from app.services.pl_product_display import (
  104. load_crm_product_alias_maps,
  105. resolve_product_display_names,
  106. )
  107. from app.schemas.pl.transport import (
  108. PlanArrangeInfoRead,
  109. TransportInventoryLinkRead,
  110. TransportIoOrderBrief,
  111. TransportSiteLinkRead,
  112. )
  113. from app.services.pl_dibang_bridge_service import (
  114. StopDibangOption,
  115. revoke_regeneratable_dibang_for_transport,
  116. sync_linked_dibang_dates_for_plan,
  117. upsert_stop_dibang_links,
  118. )
  119. from app.services.pl_inventory_link_read import build_transport_inventory_link_read
  120. from app.services.pl_io_order_service import get_active_io_order_for_plan, void_io_orders_for_plan
  121. from app.services.pl_internal_customer import plan_requires_site_link
  122. from app.services.pl_plan_site_link_service import build_site_records_for_arranged_plan
  123. from app.services.pl_transport_site_service import revoke_site_links_for_transport
  124. from app.services.pl_inventory_service import (
  125. apply_archive_inventory_for_plan,
  126. apply_archive_inventory_for_plan_stops,
  127. lock_archive_inventory,
  128. )
  129. from app.services.pl_plan_archive_stop_service import (
  130. apply_archive_stop_quantities,
  131. sum_delivery_outbound_qty,
  132. sync_plan_products_from_stop_archive,
  133. )
  134. from app.services.pl_plan_archive_export import (
  135. ARCHIVE_STATUS_LABELS,
  136. FOLLOW_STATUS_LABELS,
  137. PLAN_TYPE_LABELS,
  138. _format_plate_display,
  139. build_plan_archive_export_rows,
  140. export_plan_archive_xlsx_response,
  141. export_plan_list_xlsx_response,
  142. format_stop_contract_export_label,
  143. )
  144. from app.services.pl_plan_price_mask import (
  145. mask_plan_with_products_prices,
  146. user_can_archive_plan,
  147. user_can_view_archive_prices,
  148. )
  149. from app.services.pl_calendar_customer_names import attach_calendar_routes
  150. from app.services.pl_calendar_vehicle_types import attach_calendar_vehicle_types
  151. from app.services.pl_plan_list_attachments import load_plan_document_attachments_by_plan_ids
  152. from app.services.pl_plan_print_service import get_delivery_slip_print_context
  153. from app.services.pl_plan_stop_rules import assert_load_delivery_same_products
  154. from app.services.pl_plan_stop_service import (
  155. attach_plan_stops_to_item,
  156. collect_stop_qty_warnings,
  157. load_plan_stops_grouped,
  158. replace_plan_stops,
  159. sync_plan_legacy_from_stops,
  160. uses_multi_stop_payload,
  161. assert_external_stop_products_have_contract_line,
  162. validate_plan_stops_write,
  163. validate_mobile_draft_stops,
  164. has_pending_stop_products,
  165. validate_mobile_stop_sources,
  166. )
  167. from app.services.pl_customer_stop_pref_service import safe_upsert_prefs_from_plan_stops
  168. from app.services.pl.plan_list_export_segments import (
  169. format_stop_segment_label as _format_stop_segment_label,
  170. iter_segment_product_slots as _iter_segment_product_slots,
  171. )
  172. from app.services.pl.plan_list_filters import (
  173. apply_stock_role_filter,
  174. build_plan_column_filters,
  175. parse_single_stop_kind,
  176. parse_stop_customer_keywords,
  177. plan_plate_match,
  178. plan_product_name_exists,
  179. )
  180. from app.services.pl_plan_service import (
  181. apply_archive_brand_qtys_only,
  182. apply_archive_freight,
  183. apply_archive_product_quantities,
  184. apply_archive_stop_feedback_attachments,
  185. apply_plan_driver_feedback,
  186. apply_plan_site_feedback,
  187. apply_vehicle_prefill_to_plan,
  188. assert_contract_selectable_for_plan,
  189. is_belongs_company_contract_error,
  190. apply_plan_product_fields,
  191. build_plan_product,
  192. calc_plan_total_amount,
  193. clear_draft_transport_for_plan,
  194. clear_hazard_cert_ready,
  195. collect_hazard_product_ids,
  196. determine_plan_type,
  197. fetch_hazard_flags_map,
  198. load_plan_contracts_grouped,
  199. plan_has_hazard_product_exists,
  200. record_plan_change,
  201. replace_plan_contracts,
  202. resolve_hazard_flags_from_crm,
  203. set_hazard_cert_ready,
  204. set_plan_erp_entered,
  205. sync_transport_prefill_for_plan,
  206. update_plan_product_certs,
  207. assert_can_save_as_draft,
  208. resolve_follow_status_on_save,
  209. validate_create_full,
  210. validate_draft_min_fields,
  211. validate_hazard_cert_ready_for_transport,
  212. validate_plan_party_contracts,
  213. )
  214. router = APIRouter(prefix="/plan", tags=["运输计划-计划管理"])
  215. _PLAN_NO_SEQ_WIDTH = 3
  216. def _resolve_list_product_display(
  217. product: PlPlanProduct,
  218. *,
  219. standard_by_id: dict[str, str],
  220. alias_options_by_id: dict[str, list[str]],
  221. ) -> tuple[str, Optional[str]]:
  222. """列表展示用:优先 CRM 标准名,并返回反推别名(详情仍返回库内 product_name)。"""
  223. display_name, alias = resolve_product_display_names(
  224. saved_product_name=product.product_name,
  225. product_id=product.product_id,
  226. standard_by_id=standard_by_id,
  227. alias_options_by_id=alias_options_by_id,
  228. )
  229. return display_name or (product.product_name or ""), alias
  230. def _resolve_create_date(value: Optional[date]) -> date:
  231. return value or date.today()
  232. def _generate_plan_no(db: Session) -> str:
  233. """生成计划编号 POYYYYMMDD###(当日 3 位序号,每日重置)"""
  234. today = date.today()
  235. prefix = f"PO{today.strftime('%Y%m%d')}"
  236. # uk_plan_no 对全表生效(含软删),取号时不能过滤 del_flag
  237. rows = (
  238. db.query(PlPlan.plan_no)
  239. .filter(PlPlan.plan_no.like(f"{prefix}%"))
  240. .all()
  241. )
  242. max_seq = 0
  243. for (plan_no,) in rows:
  244. suffix = (plan_no or "")[len(prefix) :]
  245. if suffix.isdigit():
  246. max_seq = max(max_seq, int(suffix))
  247. seq = max_seq + 1
  248. return f"{prefix}{seq:0{_PLAN_NO_SEQ_WIDTH}d}"
  249. _SUBPACK_SLIP_NO_SEQ_WIDTH = 3
  250. _SUBPACK_SLIP_NO_CANONICAL_PATTERN = re.compile(r"^NO:SXFZ(\d{8})-(\d+)$")
  251. _SUBPACK_SLIP_NO_BARE_PATTERN = re.compile(r"^SXFZ(\d{8})-(\d+)$")
  252. _SUBPACK_SLIP_NO_LEGACY_PATTERNS = (
  253. re.compile(r"^NO:SXFZ-(\d{4})-(\d{2})-(\d{2})-(\d+)$"),
  254. re.compile(r"^SXFZ-(\d{4})-(\d{2})-(\d{2})-(\d+)$"),
  255. )
  256. def _format_subpack_slip_no(day: str, seq: int) -> str:
  257. return f"NO:SXFZ{day}-{int(seq):0{_SUBPACK_SLIP_NO_SEQ_WIDTH}d}"
  258. def _normalize_subpack_slip_no(slip_no: str) -> Optional[str]:
  259. """将历史分装单号统一为 NO:SXFZYYYYMMDD-###"""
  260. raw = (slip_no or "").strip()
  261. if not raw:
  262. return None
  263. matched = _SUBPACK_SLIP_NO_CANONICAL_PATTERN.match(raw)
  264. if matched:
  265. day, seq = matched.groups()
  266. return _format_subpack_slip_no(day, int(seq))
  267. matched = _SUBPACK_SLIP_NO_BARE_PATTERN.match(raw)
  268. if matched:
  269. day, seq = matched.groups()
  270. return _format_subpack_slip_no(day, int(seq))
  271. for pattern in _SUBPACK_SLIP_NO_LEGACY_PATTERNS:
  272. matched = pattern.match(raw)
  273. if matched:
  274. year, month, day, seq = matched.groups()
  275. return _format_subpack_slip_no(f"{year}{month}{day}", int(seq))
  276. return None
  277. def _subpack_slip_no_seq(slip_no: str, day_prefix: str) -> int:
  278. normalized = _normalize_subpack_slip_no(slip_no)
  279. if not normalized or not normalized.startswith(day_prefix):
  280. return 0
  281. suffix = normalized[len(day_prefix) :]
  282. return int(suffix) if suffix.isdigit() else 0
  283. def _ensure_subpack_slip_no(db: Session, tenant_no: str, plan: PlPlan) -> str:
  284. """分配分装单号 NO:SXFZYYYYMMDD-###(按租户每日递增)"""
  285. existing = (plan.subpack_slip_no or "").strip()
  286. if existing:
  287. normalized = _normalize_subpack_slip_no(existing)
  288. if normalized:
  289. if normalized != existing:
  290. plan.subpack_slip_no = normalized
  291. return normalized
  292. today = date.today()
  293. prefix = f"NO:SXFZ{today.strftime('%Y%m%d')}-"
  294. rows = (
  295. db.query(PlPlan.subpack_slip_no)
  296. .filter(
  297. PlPlan.tenant_no == tenant_no,
  298. PlPlan.subpack_slip_no.isnot(None),
  299. PlPlan.subpack_slip_no != "",
  300. )
  301. .all()
  302. )
  303. max_seq = 0
  304. for (slip_no,) in rows:
  305. max_seq = max(max_seq, _subpack_slip_no_seq(slip_no or "", prefix))
  306. next_no = f"{prefix}{max_seq + 1:0{_SUBPACK_SLIP_NO_SEQ_WIDTH}d}"
  307. plan.subpack_slip_no = next_no
  308. return next_no
  309. def _determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
  310. """根据供应商和目的地自动判断单据类型"""
  311. return determine_plan_type(supplier_name, destination_name)
  312. def _get_plan_type_label(plan_type: str) -> str:
  313. labels = {
  314. "direct": "直销单",
  315. "inbound": "入库单",
  316. "outbound": "出库单",
  317. }
  318. return labels.get(plan_type, plan_type)
  319. def _generate_notify_message(plan: PlPlan, products: list) -> str:
  320. """生成通知信息"""
  321. product_names = "、".join([p.product_name for p in products[:3]])
  322. if len(products) > 3:
  323. product_names += "等"
  324. return f"""计划号:{plan.plan_no}
  325. 企业名称:{plan.customer_name or ''}
  326. 提货单位:{plan.destination_name or ''}
  327. 到货时间:{plan.contract_end_date or ''}
  328. 药品名称:{product_names}
  329. 到货吨位:{sum(float(p.inbound_estimate_qty or p.outbound_estimate_qty or 0) for p in products)}吨
  330. 备注:"""
  331. # =============================================================================
  332. # 计划列表
  333. # =============================================================================
  334. def _parse_status_csv(value: Optional[str]) -> List[str]:
  335. if not value:
  336. return []
  337. return [item.strip() for item in value.split(",") if item.strip()]
  338. def _apply_follow_status_filter(query, follow_status: Optional[str]):
  339. statuses = _parse_status_csv(follow_status)
  340. if len(statuses) == 1:
  341. return query.filter(PlPlan.follow_status == statuses[0])
  342. if len(statuses) > 1:
  343. return query.filter(PlPlan.follow_status.in_(statuses))
  344. return query
  345. def _apply_exclude_follow_status_filter(query, exclude_follow_status: Optional[str]):
  346. statuses = _parse_status_csv(exclude_follow_status)
  347. if len(statuses) == 1:
  348. return query.filter(PlPlan.follow_status != statuses[0])
  349. if len(statuses) > 1:
  350. return query.filter(~PlPlan.follow_status.in_(statuses))
  351. return query
  352. def _merge_query(request: Request, **known) -> dict:
  353. out = {k: v for k, v in known.items() if v is not None and v != ""}
  354. for k, v in request.query_params.multi_items():
  355. if k in ("page", "pageSize"):
  356. continue
  357. if k not in out and v not in (None, ""):
  358. out[k] = v
  359. return out
  360. def _param_get(params: dict, *keys: str) -> Any:
  361. for key in keys:
  362. if key in params and params[key] not in (None, ""):
  363. return params[key]
  364. return None
  365. def _filters_from_params(params: dict, *, db: Session, tenant_no: str) -> list:
  366. return build_plan_column_filters(
  367. db=db,
  368. tenant_no=tenant_no,
  369. plan_no=_param_get(params, "planNo", "plan_no"),
  370. plan_no_in=_param_get(params, "planNoIn", "plan_no_in"),
  371. belongs_company=_param_get(params, "belongsCompany", "belongs_company"),
  372. belongs_company_in=_param_get(
  373. params, "belongsCompanyIn", "belongs_company_in"
  374. ),
  375. plan_type=_param_get(params, "planType", "plan_type"),
  376. plan_type_in=_param_get(params, "planTypeIn", "plan_type_in"),
  377. transport_status=_param_get(params, "transportStatus", "transport_status"),
  378. transport_status_in=_param_get(params, "transportStatusIn", "transport_status_in"),
  379. archive_status=_param_get(params, "archiveStatus", "archive_status"),
  380. archive_status_in=_param_get(params, "archiveStatusIn", "archive_status_in"),
  381. archive_list=_truthy_param(_param_get(params, "archiveList", "archive_list")),
  382. customer_name=_param_get(params, "customerName", "customer_name"),
  383. product_name=_param_get(params, "productName", "product_name"),
  384. product_name_in=_param_get(params, "productNameIn", "product_name_in"),
  385. package_spec=_param_get(params, "packageSpec", "package_spec"),
  386. package_spec_in=_param_get(params, "packageSpecIn", "package_spec_in"),
  387. license_plate=_param_get(params, "licensePlate", "license_plate"),
  388. license_plate_in=_param_get(params, "licensePlateIn", "license_plate_in"),
  389. stop_kind=_param_get(params, "stopKind", "stop_kind"),
  390. stop_kind_in=_param_get(params, "stopKindIn", "stop_kind_in"),
  391. stop_customers=_param_get(params, "stopCustomers", "stop_customers"),
  392. load_customers_in=_param_get(params, "loadCustomersIn", "load_customers_in"),
  393. delivery_customers_in=_param_get(params, "deliveryCustomersIn", "delivery_customers_in"),
  394. plan_date_from=_param_get(params, "planDateFrom", "plan_date_from"),
  395. plan_date_to=_param_get(params, "planDateTo", "plan_date_to"),
  396. delivery_time_from=_param_get(params, "deliveryTimeFrom", "delivery_time_from"),
  397. delivery_time_to=_param_get(params, "deliveryTimeTo", "delivery_time_to"),
  398. cert_upload_status=_param_get(params, "certUploadStatus", "cert_upload_status"),
  399. hazard_cert_ready=_param_get(params, "hazardCertReady", "hazard_cert_ready"),
  400. hazard_cert_ready_in=_param_get(
  401. params, "hazardCertReadyIn", "hazard_cert_ready_in"
  402. ),
  403. supplier_name=_param_get(params, "supplierName", "supplier_name"),
  404. destination_name=_param_get(params, "destinationName", "destination_name"),
  405. erp_entered=_param_get(params, "erpEntered", "erp_entered"),
  406. erp_entered_in=_param_get(params, "erpEnteredIn", "erp_entered_in"),
  407. carrier_name=_param_get(params, "carrierName", "carrier_name"),
  408. carrier_name_in=_param_get(params, "carrierNameIn", "carrier_name_in"),
  409. personnel_summary=_param_get(params, "personnelSummary", "personnel_summary"),
  410. follow_user_name=_param_get(params, "followUserName", "follow_user_name"),
  411. stop_address=_param_get(params, "stopAddress", "stop_address"),
  412. contract_no=_param_get(params, "contractNo", "contract_no"),
  413. contract_no_in=_param_get(params, "contractNoIn", "contract_no_in"),
  414. product_spec=_param_get(params, "productSpec", "product_spec"),
  415. product_spec_in=_param_get(params, "productSpecIn", "product_spec_in"),
  416. product_unit=_param_get(params, "productUnit", "product_unit"),
  417. product_unit_in=_param_get(params, "productUnitIn", "product_unit_in"),
  418. product_plan_qty_op=_param_get(params, "productPlanQtyOp", "product_plan_qty_op"),
  419. product_plan_qty=_param_get(params, "productPlanQty", "product_plan_qty"),
  420. product_plan_qty_min=_param_get(
  421. params, "productPlanQtyMin", "product_plan_qty_min"
  422. ),
  423. product_plan_qty_max=_param_get(
  424. params, "productPlanQtyMax", "product_plan_qty_max"
  425. ),
  426. product_actual_qty_op=_param_get(
  427. params, "productActualQtyOp", "product_actual_qty_op"
  428. ),
  429. product_actual_qty=_param_get(params, "productActualQty", "product_actual_qty"),
  430. product_actual_qty_min=_param_get(
  431. params, "productActualQtyMin", "product_actual_qty_min"
  432. ),
  433. product_actual_qty_max=_param_get(
  434. params, "productActualQtyMax", "product_actual_qty_max"
  435. ),
  436. product_inbound_qty_op=_param_get(
  437. params, "productInboundQtyOp", "product_inbound_qty_op"
  438. ),
  439. product_inbound_qty=_param_get(
  440. params, "productInboundQty", "product_inbound_qty"
  441. ),
  442. product_inbound_qty_min=_param_get(
  443. params, "productInboundQtyMin", "product_inbound_qty_min"
  444. ),
  445. product_inbound_qty_max=_param_get(
  446. params, "productInboundQtyMax", "product_inbound_qty_max"
  447. ),
  448. product_outbound_qty_op=_param_get(
  449. params, "productOutboundQtyOp", "product_outbound_qty_op"
  450. ),
  451. product_outbound_qty=_param_get(
  452. params, "productOutboundQty", "product_outbound_qty"
  453. ),
  454. product_outbound_qty_min=_param_get(
  455. params, "productOutboundQtyMin", "product_outbound_qty_min"
  456. ),
  457. product_outbound_qty_max=_param_get(
  458. params, "productOutboundQtyMax", "product_outbound_qty_max"
  459. ),
  460. product_unit_price_op=_param_get(
  461. params, "productUnitPriceOp", "product_unit_price_op"
  462. ),
  463. product_unit_price=_param_get(params, "productUnitPrice", "product_unit_price"),
  464. product_unit_price_min=_param_get(
  465. params, "productUnitPriceMin", "product_unit_price_min"
  466. ),
  467. product_unit_price_max=_param_get(
  468. params, "productUnitPriceMax", "product_unit_price_max"
  469. ),
  470. product_amount_op=_param_get(params, "productAmountOp", "product_amount_op"),
  471. product_amount=_param_get(params, "productAmount", "product_amount"),
  472. product_amount_min=_param_get(params, "productAmountMin", "product_amount_min"),
  473. product_amount_max=_param_get(params, "productAmountMax", "product_amount_max"),
  474. stop_dibang=_param_get(params, "stopDibang", "stop_dibang"),
  475. stop_dibang_in=_param_get(params, "stopDibangIn", "stop_dibang_in"),
  476. )
  477. def _body_to_params(body: BaseModel) -> dict:
  478. data = body.model_dump(by_alias=True, exclude_none=True)
  479. for key in ("ids", "columns", "exportFormat", "export_format"):
  480. data.pop(key, None)
  481. extra = getattr(body, "model_extra", None) or {}
  482. for key, value in extra.items():
  483. if key not in data and value not in (None, ""):
  484. data[key] = value
  485. return data
  486. class PlanColumnFacetBody(BaseModel):
  487. """运输计划列头可选值(去重)。额外字段与列表过滤同名,用于 Excel 级联上下文。"""
  488. model_config = ConfigDict(extra="allow")
  489. field: str = Field(
  490. ...,
  491. description="planNo | licensePlate | carrierName | loadCustomers | deliveryCustomers | contractNo | productName | productSpec | packageSpec | productUnit",
  492. )
  493. keyword: Optional[str] = Field(default=None, description="可选值关键字")
  494. limit: Optional[int] = Field(default=500, ge=1, le=500, description="返回条数上限")
  495. def _truthy_param(value: Any) -> bool:
  496. if value is True:
  497. return True
  498. if value in (1, "1", "true", "True", "yes", "YES"):
  499. return True
  500. return False
  501. def _build_plan_facet_base_query(
  502. db: Session,
  503. *,
  504. tenant_no: str,
  505. user: DbUserContext,
  506. params: dict,
  507. ):
  508. """与列表同源的计划上下文查询(已排除当前 facet 列自身条件)。"""
  509. query = db.query(PlPlan).filter(
  510. PlPlan.tenant_no == tenant_no,
  511. PlPlan.del_flag == 0,
  512. )
  513. contract_gap_only = _truthy_param(
  514. _param_get(params, "contractGapOnly", "contract_gap_only")
  515. )
  516. q_kw = (_param_get(params, "q") or "")
  517. if isinstance(q_kw, str):
  518. q_kw = q_kw.strip()
  519. else:
  520. q_kw = str(q_kw).strip() if q_kw else ""
  521. if contract_gap_only:
  522. query = apply_contract_gap_keyword_filter(query, tenant_no, q_kw)
  523. elif q_kw:
  524. q_lower = q_kw.lower()
  525. query = query.filter(
  526. or_(
  527. PlPlan.plan_no.contains(q_kw),
  528. func.lower(PlPlan.supplier_name).contains(q_lower),
  529. func.lower(PlPlan.customer_name).contains(q_lower),
  530. func.lower(PlPlan.destination_name).contains(q_lower),
  531. plan_product_name_exists(db, tenant_no, q_kw),
  532. plan_plate_match(q_kw),
  533. )
  534. )
  535. follow_status = _param_get(params, "followStatus", "follow_status")
  536. query = _apply_follow_status_filter(query, follow_status)
  537. exclude_follow_status = _param_get(
  538. params, "excludeFollowStatus", "exclude_follow_status"
  539. )
  540. query = _apply_exclude_follow_status_filter(query, exclude_follow_status)
  541. has_hazard = _param_get(params, "hasHazardProduct", "has_hazard_product")
  542. if _truthy_param(has_hazard):
  543. query = query.filter(plan_has_hazard_product_exists(tenant_no))
  544. if contract_gap_only:
  545. query = query.filter(
  546. PlPlan.follow_status != "cancelled",
  547. plan_has_contract_gap_exists(tenant_no),
  548. )
  549. gap_type = _param_get(params, "gapType", "gap_type")
  550. if gap_type:
  551. query = apply_contract_gap_type_filter(query, tenant_no, str(gap_type))
  552. customer_keyword = _param_get(params, "customerKeyword", "customer_keyword")
  553. if contract_gap_only:
  554. query = apply_contract_gap_keyword_filter(
  555. query, tenant_no, str(customer_keyword or ""), include_plan_no=False
  556. )
  557. elif customer_keyword and str(customer_keyword).strip():
  558. kw = str(customer_keyword).strip()
  559. kw_lower = kw.lower()
  560. query = query.filter(
  561. or_(
  562. func.lower(PlPlan.supplier_name).contains(kw_lower),
  563. func.lower(PlPlan.customer_name).contains(kw_lower),
  564. )
  565. )
  566. column_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
  567. if column_filters:
  568. query = query.filter(*column_filters)
  569. stock_role = _param_get(params, "stockRole", "stock_role")
  570. try:
  571. query = apply_stock_role_filter(
  572. query, stock_role=stock_role, tenant_no=tenant_no
  573. )
  574. except ValueError as exc:
  575. raise HTTPException(status_code=400, detail=str(exc)) from exc
  576. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  577. return query
  578. @router.post("/column-facet-values")
  579. def plan_column_facet_values(
  580. body: PlanColumnFacetBody,
  581. db: Session = Depends(get_db),
  582. user: DbUserContext = Depends(get_db_current_user),
  583. ):
  584. """运输计划列头可选值:在列表上下文(排除本列)下去重,对齐 Excel 级联。"""
  585. from sqlalchemy import func as sa_func
  586. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  587. lim = min(max(1, int(body.limit or 500)), 500)
  588. kw = (body.keyword or "").strip()
  589. field = str(body.field or "").strip()
  590. raw_params = _body_to_params(body)
  591. params = strip_plan_facet_self_params(raw_params, field)
  592. base = _build_plan_facet_base_query(
  593. db, tenant_no=tenant_no, user=user, params=params
  594. )
  595. plan_id_sq = base.with_entities(PlPlan.id).subquery()
  596. def _finish(rows):
  597. out = []
  598. for row in rows:
  599. value = str(row.value).strip() if row.value is not None else ""
  600. if not value:
  601. continue
  602. out.append({"value": value, "label": value, "count": int(row.cnt or 0)})
  603. return out
  604. def _plan_col_facet(col):
  605. q = (
  606. db.query(col.label("value"), sa_func.count(PlPlan.id).label("cnt"))
  607. .select_from(PlPlan)
  608. .filter(
  609. PlPlan.id.in_(plan_id_sq),
  610. col.isnot(None),
  611. col != "",
  612. )
  613. )
  614. if kw:
  615. q = q.filter(col.like(f"%{kw}%"))
  616. return _finish(q.group_by(col).order_by(col.asc()).limit(lim).all())
  617. def _sub_facet(model, col):
  618. q = (
  619. db.query(col.label("value"), sa_func.count(PlPlan.id).label("cnt"))
  620. .select_from(PlPlan)
  621. .join(model, model.plan_id == PlPlan.id)
  622. .filter(
  623. PlPlan.id.in_(plan_id_sq),
  624. model.tenant_no == tenant_no,
  625. model.del_flag == 0,
  626. col.isnot(None),
  627. col != "",
  628. )
  629. )
  630. if kw:
  631. q = q.filter(col.like(f"%{kw}%"))
  632. return _finish(q.group_by(col).order_by(col.asc()).limit(lim).all())
  633. if field == "planNo":
  634. return {"list": _plan_col_facet(PlPlan.plan_no)}
  635. if field == "licensePlate":
  636. return {"list": _plan_col_facet(PlPlan.license_plate)}
  637. if field in ("loadCustomers", "loadCustomer"):
  638. return {"list": _sub_facet(PlPlanLoad, PlPlanLoad.customer_name)}
  639. if field in ("deliveryCustomers", "deliveryCustomer"):
  640. return {"list": _sub_facet(PlPlanDelivery, PlPlanDelivery.customer_name)}
  641. if field == "productName":
  642. return {"list": _sub_facet(PlPlanProduct, PlPlanProduct.product_name)}
  643. if field == "productSpec":
  644. return {"list": _sub_facet(PlPlanProduct, PlPlanProduct.model)}
  645. if field in ("carrierName",):
  646. col = PlTransport.carrier_name
  647. q = (
  648. db.query(col.label("value"), sa_func.count(PlPlan.id).label("cnt"))
  649. .select_from(PlPlan)
  650. .join(PlTransport, PlTransport.id == PlPlan.transport_id)
  651. .filter(
  652. PlPlan.id.in_(plan_id_sq),
  653. PlTransport.tenant_no == tenant_no,
  654. PlTransport.del_flag == 0,
  655. col.isnot(None),
  656. col != "",
  657. )
  658. )
  659. if kw:
  660. q = q.filter(col.like(f"%{kw}%"))
  661. return {"list": _finish(q.group_by(col).order_by(col.asc()).limit(lim).all())}
  662. if field in ("packageSpec", "productPackageSpec"):
  663. return {"list": _sub_facet(PlPlanProduct, PlPlanProduct.package_spec)}
  664. if field == "productUnit":
  665. return {"list": _sub_facet(PlPlanProduct, PlPlanProduct.unit)}
  666. if field in ("contractNo", "productContract"):
  667. from app.services.pl.plan_list_filters import (
  668. CONTRACT_PLACEHOLDER_INBOUND,
  669. CONTRACT_PLACEHOLDER_OUTBOUND,
  670. plan_internal_contract_placeholder_exists,
  671. )
  672. link_q = (
  673. db.query(
  674. PlPlanContract.contract_no.label("value"),
  675. sa_func.count(PlPlan.id).label("cnt"),
  676. )
  677. .select_from(PlPlan)
  678. .join(PlPlanContract, PlPlanContract.plan_id == PlPlan.id)
  679. .filter(
  680. PlPlan.id.in_(plan_id_sq),
  681. PlPlanContract.tenant_no == tenant_no,
  682. PlPlanContract.del_flag == 0,
  683. PlPlanContract.contract_no.isnot(None),
  684. PlPlanContract.contract_no != "",
  685. )
  686. )
  687. header_q = (
  688. db.query(
  689. PlPlan.contract_no.label("value"),
  690. sa_func.count(PlPlan.id).label("cnt"),
  691. )
  692. .filter(
  693. PlPlan.id.in_(plan_id_sq),
  694. PlPlan.contract_no.isnot(None),
  695. PlPlan.contract_no != "",
  696. )
  697. )
  698. if kw:
  699. link_q = link_q.filter(PlPlanContract.contract_no.like(f"%{kw}%"))
  700. header_q = header_q.filter(PlPlan.contract_no.like(f"%{kw}%"))
  701. merged: dict = {}
  702. for row in link_q.group_by(PlPlanContract.contract_no).all():
  703. value = str(row.value).strip()
  704. if value:
  705. merged[value] = merged.get(value, 0) + int(row.cnt or 0)
  706. for row in header_q.group_by(PlPlan.contract_no).all():
  707. value = str(row.value).strip()
  708. if value:
  709. merged[value] = merged.get(value, 0) + int(row.cnt or 0)
  710. items = sorted(merged.items(), key=lambda kv: kv[0])[:lim]
  711. list_items = [{"value": v, "label": v, "count": c} for v, c in items]
  712. # 山峡/云栋缺合同占位:固定置于可选值前列,便于按入库/出库过滤
  713. placeholder_prefix = []
  714. for placeholder in (CONTRACT_PLACEHOLDER_OUTBOUND, CONTRACT_PLACEHOLDER_INBOUND):
  715. if kw and kw not in placeholder:
  716. continue
  717. base = db.query(PlPlan.id).filter(PlPlan.id.in_(plan_id_sq))
  718. cnt = (
  719. base.filter(
  720. plan_internal_contract_placeholder_exists(tenant_no, placeholder)
  721. ).count()
  722. )
  723. placeholder_prefix.append(
  724. {"value": placeholder, "label": placeholder, "count": int(cnt or 0)}
  725. )
  726. return {"list": placeholder_prefix + list_items}
  727. raise HTTPException(status_code=400, detail="不支持的列")
  728. @router.get("/", response_model=PlanListResponse)
  729. def list_plans(
  730. request: Request,
  731. q: Optional[str] = Query(
  732. default=None,
  733. description="模糊搜索(计划编号/供方名称/需方名称/产品名称/车牌号)",
  734. ),
  735. plan_no: Optional[str] = Query(default=None, description="计划编号"),
  736. plan_type: Optional[str] = Query(default=None, alias="planType", description="单据类型"),
  737. follow_status: Optional[str] = Query(
  738. default=None,
  739. alias="followStatus",
  740. description="跟进状态,多个以英文逗号分隔",
  741. ),
  742. exclude_follow_status: Optional[str] = Query(
  743. default=None,
  744. alias="excludeFollowStatus",
  745. description="排除的跟进状态,多个以英文逗号分隔",
  746. ),
  747. transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"),
  748. archive_status: Optional[str] = Query(default=None, alias="archiveStatus", description="归档状态"),
  749. customer_name: Optional[str] = Query(default=None, alias="customerName", description="客户名称"),
  750. product_name: Optional[str] = Query(default=None, alias="productName", description="产品名称"),
  751. package_spec: Optional[str] = Query(default=None, alias="packageSpec", description="包装规格"),
  752. license_plate: Optional[str] = Query(default=None, alias="licensePlate", description="车牌号"),
  753. plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom", description="计划日期开始"),
  754. plan_date_to: Optional[date] = Query(default=None, alias="planDateTo", description="计划日期结束"),
  755. delivery_time_from: Optional[date] = Query(
  756. default=None, alias="deliveryTimeFrom", description="送货时间开始"
  757. ),
  758. delivery_time_to: Optional[date] = Query(
  759. default=None, alias="deliveryTimeTo", description="送货时间结束"
  760. ),
  761. has_hazard_product: Optional[int] = Query(
  762. default=None,
  763. alias="hasHazardProduct",
  764. description="1=仅含易制毒/易制爆产品的计划",
  765. ),
  766. cert_upload_status: Optional[str] = Query(
  767. default=None,
  768. alias="certUploadStatus",
  769. description="incomplete=至少一张必需危化证书未上传",
  770. ),
  771. hazard_cert_ready: Optional[int] = Query(
  772. default=None,
  773. alias="hazardCertReady",
  774. description="0=未确认齐备 1=已确认齐备",
  775. ),
  776. contract_id: Optional[str] = Query(
  777. default=None,
  778. alias="contractId",
  779. description="CRM合同ID:返回关联该合同的运输计划(含 pl_plan_contract 与遗留主表 contract_id)",
  780. ),
  781. include_customer_short_names: int = Query(
  782. default=0, alias="includeCustomerShortNames", description="1=返回日历客户简称路线和车辆归属"
  783. ),
  784. include_document_attachments: Optional[int] = Query(
  785. default=0,
  786. alias="includeDocumentAttachments",
  787. description="1=聚合单据附件(归档列表需要;默认关闭以降低列表耗时)",
  788. ),
  789. include_arrange_copy_flags: Optional[int] = Query(
  790. default=0,
  791. alias="includeArrangeCopyFlags",
  792. description="1=计算复制给司机/现场/磅单标志(运输计划列表需要;默认关闭)",
  793. ),
  794. stock_role: Optional[str] = Query(
  795. default=None,
  796. alias="stockRole",
  797. description="inbound=送货段山峡/云栋;outbound=装货段山峡/云栋",
  798. ),
  799. page: int = Query(default=1, ge=1),
  800. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  801. db: Session = Depends(get_db),
  802. user: DbUserContext = Depends(get_db_current_user),
  803. ) -> PlanListResponse:
  804. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  805. query = db.query(PlPlan).filter(
  806. PlPlan.tenant_no == tenant_no,
  807. PlPlan.del_flag == 0,
  808. )
  809. if contract_id and str(contract_id).strip():
  810. from app.services.pl_contract_plan_qty import plan_ids_linked_to_contract
  811. linked_ids = plan_ids_linked_to_contract(
  812. db, tenant_no=tenant_no, contract_id=str(contract_id).strip()
  813. )
  814. if not linked_ids:
  815. return PlanListResponse(total=0, list=[])
  816. query = query.filter(PlPlan.id.in_(linked_ids))
  817. if q:
  818. q_kw = q.strip()
  819. if q_kw:
  820. q_lower = q_kw.lower()
  821. query = query.filter(
  822. or_(
  823. PlPlan.plan_no.contains(q_kw),
  824. func.lower(PlPlan.supplier_name).contains(q_lower),
  825. func.lower(PlPlan.customer_name).contains(q_lower),
  826. func.lower(PlPlan.destination_name).contains(q_lower),
  827. plan_product_name_exists(db, tenant_no, q_kw),
  828. plan_plate_match(q_kw),
  829. )
  830. )
  831. query = _apply_follow_status_filter(query, follow_status)
  832. query = _apply_exclude_follow_status_filter(query, exclude_follow_status)
  833. if has_hazard_product == 1:
  834. query = query.filter(plan_has_hazard_product_exists(tenant_no))
  835. params = _merge_query(
  836. request,
  837. planNo=plan_no,
  838. planType=plan_type,
  839. transportStatus=transport_status,
  840. archiveStatus=archive_status,
  841. customerName=customer_name,
  842. productName=product_name,
  843. packageSpec=package_spec,
  844. licensePlate=license_plate,
  845. planDateFrom=plan_date_from,
  846. planDateTo=plan_date_to,
  847. deliveryTimeFrom=delivery_time_from,
  848. deliveryTimeTo=delivery_time_to,
  849. certUploadStatus=cert_upload_status,
  850. hazardCertReady=hazard_cert_ready,
  851. )
  852. column_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
  853. if column_filters:
  854. query = query.filter(*column_filters)
  855. try:
  856. query = apply_stock_role_filter(
  857. query, stock_role=stock_role, tenant_no=tenant_no
  858. )
  859. except ValueError as exc:
  860. raise HTTPException(status_code=400, detail=str(exc)) from exc
  861. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  862. # 统计总数
  863. total = query.count()
  864. # 分页
  865. offset = (page - 1) * page_size
  866. sf, so = parse_sort_params(params)
  867. plans = (
  868. query.order_by(order_plan(sf, so), PlPlan.id.desc())
  869. .offset(offset)
  870. .limit(page_size)
  871. .all()
  872. )
  873. plan_ids = [plan.id for plan in plans]
  874. products_by_plan: dict[str, list[PlPlanProduct]] = {}
  875. all_products: list[PlPlanProduct] = []
  876. if plan_ids:
  877. all_products = (
  878. db.query(PlPlanProduct)
  879. .filter(
  880. PlPlanProduct.plan_id.in_(plan_ids),
  881. PlPlanProduct.del_flag == 0,
  882. )
  883. .order_by(PlPlanProduct.order_num)
  884. .all()
  885. )
  886. for product in all_products:
  887. products_by_plan.setdefault(product.plan_id, []).append(product)
  888. product_ids = [product.product_id for product in all_products if product.product_id]
  889. hazard_map = fetch_hazard_flags_map(
  890. db,
  891. tenant_no,
  892. product_ids,
  893. )
  894. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  895. db,
  896. tenant_no=tenant_no,
  897. product_ids=product_ids,
  898. )
  899. brand_qtys_by_pp = load_brand_qtys_by_plan_product_ids(
  900. db,
  901. tenant_no=tenant_no,
  902. plan_product_ids=[p.id for p in all_products],
  903. )
  904. contracts_by_plan = load_plan_contracts_grouped(db, tenant_no, plan_ids)
  905. need_copy_flags = int(include_arrange_copy_flags or 0) == 1
  906. need_document_attachments = int(include_document_attachments or 0) == 1
  907. execution_locks = load_plan_execution_locks(db, tenant_no, plan_ids)
  908. arrange_copy_flags = (
  909. load_plan_arrange_copy_flags(db, tenant_no, plans) if need_copy_flags else {}
  910. )
  911. document_attachments_by_plan = (
  912. load_plan_document_attachments_by_plan_ids(
  913. db,
  914. tenant_no=tenant_no,
  915. plan_ids=plan_ids,
  916. )
  917. if need_document_attachments
  918. else {}
  919. )
  920. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  921. can_price = user_can_view_archive_prices(db, user)
  922. result_list: list[PlanWithProducts] = []
  923. for plan in plans:
  924. enriched_products: list[PlanProductRead] = []
  925. for product in products_by_plan.get(plan.id, []):
  926. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  927. db,
  928. tenant_no,
  929. product.product_id,
  930. hazard_map=hazard_map,
  931. )
  932. item = PlanProductRead.model_validate(product)
  933. raw_brand_qtys = brand_qtys_by_pp.get(product.id) or None
  934. brand_qtys = (
  935. [PlanArchiveBrandQtyItem.model_validate(x) for x in raw_brand_qtys]
  936. if raw_brand_qtys
  937. else None
  938. )
  939. list_product_name, list_alias_name = _resolve_list_product_display(
  940. product,
  941. standard_by_id=standard_by_id,
  942. alias_options_by_id=alias_options_by_id,
  943. )
  944. item = item.model_copy(
  945. update={
  946. "is_toxic": is_toxic,
  947. "is_explosive": is_explosive,
  948. "brand_qtys": brand_qtys,
  949. "product_name": list_product_name,
  950. "alias_name": list_alias_name,
  951. }
  952. )
  953. enriched_products.append(item)
  954. plan_item = PlanWithProducts.model_validate(plan)
  955. plan_item.products = enriched_products
  956. supplier_contracts, demander_contracts = contracts_by_plan.get(plan.id, ([], []))
  957. plan_item.supplier_contracts = supplier_contracts
  958. plan_item.demander_contracts = demander_contracts
  959. flags = arrange_copy_flags.get(plan.id)
  960. if flags:
  961. plan_item.can_copy_to_driver = flags.can_copy_to_driver
  962. plan_item.can_copy_to_site = flags.can_copy_to_site
  963. plan_item.can_copy_pound_qrcode = flags.can_copy_pound_qrcode
  964. plan_item.execution_lock_reason = execution_locks.get(plan.id)
  965. plan_item.execution_started = bool(plan_item.execution_lock_reason)
  966. if not (plan_item.personnel_summary or "").strip():
  967. snapshot = plan_item.resource_snapshot
  968. if snapshot and snapshot.personnel_summary:
  969. plan_item.personnel_summary = snapshot.personnel_summary
  970. elif plan_item.driver_name:
  971. plan_item.personnel_summary = f"{plan_item.driver_name}(驾)"
  972. plan_item.document_attachments = document_attachments_by_plan.get(plan.id, [])
  973. attach_plan_stops_to_item(
  974. plan_item,
  975. plan,
  976. stops_by_plan.get(plan.id, (None, [])),
  977. )
  978. plan_item = mask_plan_with_products_prices(plan_item, enabled=not can_price)
  979. result_list.append(plan_item)
  980. if include_customer_short_names == 1:
  981. attach_calendar_routes(db, tenant_no=tenant_no, plans=result_list)
  982. attach_calendar_vehicle_types(db, tenant_no=tenant_no, plans=result_list)
  983. return PlanListResponse(total=total, list=result_list)
  984. PLAN_EXPORT_COLUMNS = [
  985. ExportColumn(title="计划编号", field="planNo"),
  986. ExportColumn(title="段次", field="stopSegment"),
  987. ExportColumn(title="归属公司", field="belongsCompany"),
  988. ExportColumn(title="跟进状态", field="followStatus"),
  989. ExportColumn(title="计划日期", field="planDate"),
  990. ExportColumn(title="送货时间", field="deliveryTimes"),
  991. ExportColumn(title="单据类型", field="planType"),
  992. ExportColumn(title="车牌号", field="licensePlate"),
  993. ExportColumn(title="人员", field="personnelSummary"),
  994. ExportColumn(title="跟进人", field="followUserName"),
  995. ExportColumn(title="装货客户", field="loadCustomer"),
  996. ExportColumn(title="送货客户", field="deliveryCustomer"),
  997. ExportColumn(title="装货地址/时间", field="loadAddressTime"),
  998. ExportColumn(title="送货地址/时间", field="deliveryAddressTime"),
  999. ExportColumn(title="合同", field="productContract"),
  1000. ExportColumn(title="产品名称", field="productName"),
  1001. ExportColumn(title="品牌", field="brandName"),
  1002. ExportColumn(title="别名", field="aliasName"),
  1003. ExportColumn(title="型号/规格", field="model"),
  1004. ExportColumn(title="包装规格", field="packageSpec"),
  1005. ExportColumn(title="单位", field="unit", dict_type="crm_product_unit"),
  1006. ExportColumn(title="计划量", field="planQty"),
  1007. ExportColumn(title="实际量", field="actualQty"),
  1008. ExportColumn(title="装货地磅", field="loadDibang"),
  1009. ExportColumn(title="送货地磅", field="deliveryDibang"),
  1010. ExportColumn(title="供方", field="supplierName"),
  1011. ExportColumn(title="需方", field="customerName"),
  1012. ExportColumn(title="司机", field="driverName"),
  1013. ExportColumn(title="运输状态", field="transportStatus"),
  1014. ExportColumn(title="归档状态", field="archiveStatus"),
  1015. ExportColumn(title="ERP", field="erpEntered"),
  1016. ExportColumn(title="备注", field="remark"),
  1017. ]
  1018. # 列表可见列 prop → 导出 field(与归档导出字段对齐)
  1019. _PLAN_EXPORT_COLUMN_ALIASES = {
  1020. "productBrand": "brandName",
  1021. "productAlias": "aliasName",
  1022. "productSpec": "model",
  1023. "productPackageSpec": "packageSpec",
  1024. "productUnit": "unit",
  1025. }
  1026. _PLAN_COMPANY_LABELS = {
  1027. "shanxia": "安庆山峡",
  1028. "yundong": "安徽云栋",
  1029. }
  1030. _TRANSPORT_STATUS_LABELS = {
  1031. "draft": "草稿",
  1032. "pending": "待执行",
  1033. "executing": "执行中",
  1034. "completed": "已完成",
  1035. "cancelled": "已作废",
  1036. "arranged": "已安排",
  1037. }
  1038. def _normalize_plan_export_columns(
  1039. requested: Optional[Sequence[str]],
  1040. ) -> Optional[list[str]]:
  1041. if not requested:
  1042. return None
  1043. return [
  1044. _PLAN_EXPORT_COLUMN_ALIASES.get(str(key), str(key)) for key in requested
  1045. ]
  1046. def _plan_list_export_filename_stem(
  1047. stock_role: Optional[str], date_str: str
  1048. ) -> str:
  1049. role = (stock_role or "").strip().lower()
  1050. if role == "inbound":
  1051. return f"入库单_{date_str}"
  1052. if role == "outbound":
  1053. return f"出库单_{date_str}"
  1054. return f"计划列表_{date_str}"
  1055. def _plan_list_export_sheet_title(stock_role: Optional[str]) -> str:
  1056. role = (stock_role or "").strip().lower()
  1057. if role == "inbound":
  1058. return "入库单"
  1059. if role == "outbound":
  1060. return "出库单"
  1061. return "计划列表"
  1062. def _format_export_date_text(value: Any) -> str:
  1063. if value is None:
  1064. return ""
  1065. text = str(value).strip()
  1066. return text[:10] if text else ""
  1067. def _format_stop_address_time(address: Any, time_value: Any) -> str:
  1068. addr = (str(address or "")).strip()
  1069. time_text = _format_export_date_text(time_value)
  1070. if addr and time_text:
  1071. return f"{addr} / {time_text}"
  1072. return addr or time_text
  1073. def _format_stop_dibang_label(stop: Any) -> str:
  1074. if stop is None:
  1075. return ""
  1076. generate = int(getattr(stop, "generate_dibang", 0) or 0)
  1077. if not generate:
  1078. return ""
  1079. direction = (getattr(stop, "dibang_direction", None) or "").strip()
  1080. if direction == "outbound":
  1081. return "出库"
  1082. if direction == "inbound":
  1083. return "入库"
  1084. if direction == "none":
  1085. return "无"
  1086. return "是"
  1087. def _apply_plan_list_export_labels(data: dict) -> None:
  1088. company = str(data.get("belongsCompany") or "")
  1089. data["belongsCompany"] = _PLAN_COMPANY_LABELS.get(company, company)
  1090. follow = str(data.get("followStatus") or "")
  1091. data["followStatus"] = FOLLOW_STATUS_LABELS.get(follow, follow)
  1092. plan_type = str(data.get("planType") or "")
  1093. data["planType"] = PLAN_TYPE_LABELS.get(plan_type, plan_type)
  1094. transport = str(data.get("transportStatus") or "")
  1095. data["transportStatus"] = _TRANSPORT_STATUS_LABELS.get(transport, transport)
  1096. archive = str(data.get("archiveStatus") or "")
  1097. data["archiveStatus"] = ARCHIVE_STATUS_LABELS.get(archive, archive)
  1098. def _apply_plan_list_export_segment_stop_fields(
  1099. data: dict,
  1100. *,
  1101. kind: str,
  1102. load_read: Any = None,
  1103. delivery_read: Any = None,
  1104. delivery_seq: Optional[int] = None,
  1105. ) -> None:
  1106. """按当前导出行所属段填写装/送侧字段;对侧留空。"""
  1107. data["_stopKind"] = kind
  1108. data["_deliverySeq"] = delivery_seq
  1109. data["stopSegment"] = _format_stop_segment_label(kind, delivery_seq)
  1110. data["loadCustomer"] = ""
  1111. data["deliveryCustomer"] = ""
  1112. data["loadAddressTime"] = ""
  1113. data["deliveryAddressTime"] = ""
  1114. data["deliveryTimes"] = ""
  1115. data["productContract"] = ""
  1116. data["loadDibang"] = ""
  1117. data["deliveryDibang"] = ""
  1118. if kind == "load" and load_read is not None:
  1119. data["loadCustomer"] = (getattr(load_read, "customer_name", None) or "").strip()
  1120. data["loadAddressTime"] = _format_stop_address_time(
  1121. getattr(load_read, "address", None),
  1122. getattr(load_read, "load_time", None),
  1123. )
  1124. data["loadDibang"] = _format_stop_dibang_label(load_read)
  1125. # 与列表/归档一致:有编号导出编号;山峡/云栋缺合同 → 出库
  1126. data["productContract"] = format_stop_contract_export_label(
  1127. "load",
  1128. getattr(load_read, "contracts", None),
  1129. data["loadCustomer"],
  1130. )
  1131. return
  1132. if kind == "delivery" and delivery_read is not None:
  1133. data["deliveryCustomer"] = (
  1134. getattr(delivery_read, "customer_name", None) or ""
  1135. ).strip()
  1136. data["deliveryAddressTime"] = _format_stop_address_time(
  1137. getattr(delivery_read, "address", None),
  1138. getattr(delivery_read, "delivery_time", None),
  1139. )
  1140. data["deliveryTimes"] = _format_export_date_text(
  1141. getattr(delivery_read, "delivery_time", None)
  1142. )
  1143. data["deliveryDibang"] = _format_stop_dibang_label(delivery_read)
  1144. # 与列表/归档一致:有编号导出编号;山峡/云栋缺合同 → 入库
  1145. data["productContract"] = format_stop_contract_export_label(
  1146. "delivery",
  1147. getattr(delivery_read, "contracts", None),
  1148. data["deliveryCustomer"],
  1149. )
  1150. def _blank_plan_list_export_stop_fields(data: dict) -> None:
  1151. data["stopSegment"] = ""
  1152. data["loadCustomer"] = ""
  1153. data["deliveryCustomer"] = ""
  1154. data["loadAddressTime"] = ""
  1155. data["deliveryAddressTime"] = ""
  1156. data["deliveryTimes"] = ""
  1157. data["productContract"] = ""
  1158. data["loadDibang"] = ""
  1159. data["deliveryDibang"] = ""
  1160. def _blank_plan_list_export_product_fields(row: dict) -> None:
  1161. row["productName"] = ""
  1162. row["brandName"] = ""
  1163. row["aliasName"] = ""
  1164. row["model"] = ""
  1165. row["packageSpec"] = ""
  1166. row["unit"] = ""
  1167. row["planQty"] = None
  1168. row["actualQty"] = None
  1169. def _index_plan_products_for_list_export(
  1170. plan_products: Sequence[Any],
  1171. ) -> tuple[dict[str, Any], dict[str, Any]]:
  1172. by_id: dict[str, Any] = {}
  1173. by_product_id: dict[str, Any] = {}
  1174. for product in plan_products:
  1175. plan_product_id = str(getattr(product, "id", None) or "").strip()
  1176. if plan_product_id:
  1177. by_id[plan_product_id] = product
  1178. product_id = str(getattr(product, "product_id", None) or "").strip()
  1179. if product_id and product_id not in by_product_id:
  1180. by_product_id[product_id] = product
  1181. return by_id, by_product_id
  1182. def _lookup_plan_list_export_plan_product(
  1183. stop_product: Any,
  1184. plan_products_by_id: dict[str, Any],
  1185. plan_products_by_product_id: dict[str, Any],
  1186. ) -> Optional[Any]:
  1187. plan_product_id = str(getattr(stop_product, "plan_product_id", None) or "").strip()
  1188. if plan_product_id and plan_product_id in plan_products_by_id:
  1189. return plan_products_by_id[plan_product_id]
  1190. product_id = str(getattr(stop_product, "product_id", None) or "").strip()
  1191. if product_id and product_id in plan_products_by_product_id:
  1192. return plan_products_by_product_id[product_id]
  1193. return None
  1194. def _fill_plan_list_export_legacy_product(
  1195. row: dict,
  1196. product: Any,
  1197. *,
  1198. standard_by_id: dict[str, str],
  1199. alias_options_by_id: dict[str, list[str]],
  1200. ) -> None:
  1201. display_name, alias = resolve_product_display_names(
  1202. saved_product_name=product.product_name,
  1203. product_id=product.product_id,
  1204. standard_by_id=standard_by_id,
  1205. alias_options_by_id=alias_options_by_id,
  1206. )
  1207. row["productName"] = display_name or (product.product_name or "")
  1208. row["brandName"] = ""
  1209. row["aliasName"] = (alias or "").strip()
  1210. row["model"] = (product.model or "").strip()
  1211. row["packageSpec"] = (product.package_spec or "").strip()
  1212. row["unit"] = (product.unit or "").strip()
  1213. row["planQty"] = product.quantity
  1214. row["actualQty"] = product.actual_qty
  1215. def _fill_plan_list_export_segment_product(
  1216. row: dict,
  1217. product: Any,
  1218. kind: str,
  1219. *,
  1220. plan_products_by_id: dict[str, Any],
  1221. plan_products_by_product_id: dict[str, Any],
  1222. standard_by_id: dict[str, str],
  1223. alias_options_by_id: dict[str, list[str]],
  1224. ) -> Optional[Any]:
  1225. """填充段产品字段;返回关联的计划产品(供品牌明细回退)。"""
  1226. if product is None:
  1227. _blank_plan_list_export_product_fields(row)
  1228. return None
  1229. plan_product = _lookup_plan_list_export_plan_product(
  1230. product, plan_products_by_id, plan_products_by_product_id
  1231. )
  1232. saved_name = getattr(product, "product_name", None) or ""
  1233. display_name, alias = resolve_product_display_names(
  1234. saved_product_name=saved_name,
  1235. product_id=getattr(product, "product_id", None),
  1236. standard_by_id=standard_by_id,
  1237. alias_options_by_id=alias_options_by_id,
  1238. )
  1239. row["productName"] = display_name or saved_name
  1240. row["brandName"] = ""
  1241. row["aliasName"] = (alias or "").strip()
  1242. row["model"] = (getattr(product, "model", None) or "").strip()
  1243. row["packageSpec"] = (getattr(product, "package_spec", None) or "").strip()
  1244. row["unit"] = (getattr(product, "unit", None) or "").strip()
  1245. row["planQty"] = getattr(product, "plan_qty", None)
  1246. if kind == "load":
  1247. row["actualQty"] = getattr(product, "inbound_qty", None)
  1248. else:
  1249. row["actualQty"] = getattr(product, "outbound_qty", None)
  1250. return plan_product
  1251. def _build_plan_list_export_rows(
  1252. db: Session,
  1253. *,
  1254. tenant_no: str,
  1255. plans: list[PlPlan],
  1256. stops_by_plan: dict,
  1257. ) -> list[dict]:
  1258. """计划列表导出:有段时按段次×产品展开;有归档品牌明细时再按品牌拆行。"""
  1259. plan_ids = [plan.id for plan in plans]
  1260. products_by_plan: dict[str, list[PlPlanProduct]] = defaultdict(list)
  1261. product_ids: set[str] = set()
  1262. plan_product_ids: list[str] = []
  1263. stop_product_ids: list[str] = []
  1264. if plan_ids:
  1265. product_rows = (
  1266. db.query(PlPlanProduct)
  1267. .filter(
  1268. PlPlanProduct.tenant_no == tenant_no,
  1269. PlPlanProduct.plan_id.in_(plan_ids),
  1270. PlPlanProduct.del_flag == 0,
  1271. )
  1272. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.id.asc())
  1273. .all()
  1274. )
  1275. for product in product_rows:
  1276. products_by_plan[product.plan_id].append(product)
  1277. if product.product_id:
  1278. product_ids.add(product.product_id)
  1279. if product.id:
  1280. plan_product_ids.append(str(product.id))
  1281. for plan in plans:
  1282. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  1283. if load_read is None and not delivery_reads:
  1284. continue
  1285. for _kind, _load, _delivery, _seq, stop_product in _iter_segment_product_slots(
  1286. load_read, delivery_reads
  1287. ):
  1288. if stop_product is None:
  1289. continue
  1290. stop_crm_id = str(getattr(stop_product, "product_id", None) or "").strip()
  1291. if stop_crm_id:
  1292. product_ids.add(stop_crm_id)
  1293. stop_row_id = str(getattr(stop_product, "id", None) or "").strip()
  1294. if stop_row_id:
  1295. stop_product_ids.append(stop_row_id)
  1296. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  1297. db, tenant_no=tenant_no, product_ids=product_ids
  1298. )
  1299. brand_by_plan_product = load_brand_qtys_by_plan_product_ids(
  1300. db, tenant_no=tenant_no, plan_product_ids=plan_product_ids
  1301. )
  1302. brand_by_stop = load_brand_qtys_by_stop_product_ids(
  1303. db, tenant_no=tenant_no, stop_product_ids=stop_product_ids
  1304. )
  1305. items: list[dict] = []
  1306. for plan in plans:
  1307. data = PlanWithProducts.model_validate(plan).model_dump(by_alias=True)
  1308. data.pop("products", None)
  1309. if data.get("planDate") is not None:
  1310. data["planDate"] = str(data["planDate"])[:10]
  1311. if data.get("erpEntered") is not None:
  1312. data["erpEntered"] = "是" if int(data["erpEntered"]) else "否"
  1313. data["licensePlate"] = _format_plate_display(
  1314. data.get("licensePlate"), data.get("trailerNo")
  1315. )
  1316. _apply_plan_list_export_labels(data)
  1317. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  1318. plan_products = products_by_plan.get(plan.id) or []
  1319. if load_read is None and not delivery_reads:
  1320. _blank_plan_list_export_stop_fields(data)
  1321. if not plan_products:
  1322. _blank_plan_list_export_product_fields(data)
  1323. data["_stopKind"] = ""
  1324. data["_deliverySeq"] = None
  1325. items.append(data)
  1326. continue
  1327. for product in plan_products:
  1328. row = dict(data)
  1329. row["_stopKind"] = ""
  1330. row["_deliverySeq"] = None
  1331. _fill_plan_list_export_legacy_product(
  1332. row,
  1333. product,
  1334. standard_by_id=standard_by_id,
  1335. alias_options_by_id=alias_options_by_id,
  1336. )
  1337. items.extend(
  1338. expand_export_rows_for_plan_brand(
  1339. row,
  1340. archive_status=getattr(plan, "archive_status", None),
  1341. plan_product=product,
  1342. stop_product_id=None,
  1343. plan_product_id=str(getattr(product, "id", None) or ""),
  1344. by_stop_id=brand_by_stop,
  1345. by_plan_product_id=brand_by_plan_product,
  1346. )
  1347. )
  1348. continue
  1349. by_id, by_product_id = _index_plan_products_for_list_export(plan_products)
  1350. for kind, slot_load, slot_delivery, seq, stop_product in _iter_segment_product_slots(
  1351. load_read, delivery_reads
  1352. ):
  1353. row = dict(data)
  1354. _apply_plan_list_export_segment_stop_fields(
  1355. row,
  1356. kind=kind,
  1357. load_read=slot_load,
  1358. delivery_read=slot_delivery,
  1359. delivery_seq=seq,
  1360. )
  1361. plan_product = _fill_plan_list_export_segment_product(
  1362. row,
  1363. stop_product,
  1364. kind,
  1365. plan_products_by_id=by_id,
  1366. plan_products_by_product_id=by_product_id,
  1367. standard_by_id=standard_by_id,
  1368. alias_options_by_id=alias_options_by_id,
  1369. )
  1370. items.extend(
  1371. expand_export_rows_for_plan_brand(
  1372. row,
  1373. archive_status=getattr(plan, "archive_status", None),
  1374. plan_product=plan_product,
  1375. stop_product_id=str(getattr(stop_product, "id", None) or "")
  1376. if stop_product is not None
  1377. else None,
  1378. plan_product_id=str(getattr(plan_product, "id", None) or "")
  1379. if plan_product is not None
  1380. else str(getattr(stop_product, "plan_product_id", None) or "")
  1381. if stop_product is not None
  1382. else None,
  1383. by_stop_id=brand_by_stop,
  1384. by_plan_product_id=brand_by_plan_product,
  1385. )
  1386. )
  1387. return items
  1388. PLAN_ARCHIVE_EXPORT_COLUMNS = [
  1389. ExportColumn(title="序号", field="rowNo"),
  1390. ExportColumn(title="计划编号", field="planNo"),
  1391. ExportColumn(title="段次", field="stopSegment"),
  1392. ExportColumn(title="合同编号", field="contractNo"),
  1393. ExportColumn(title="计划日期", field="planDate"),
  1394. ExportColumn(title="归档状态", field="archiveStatus"),
  1395. ExportColumn(title="跟进状态", field="followStatus"),
  1396. ExportColumn(title="单据类型", field="planType"),
  1397. ExportColumn(title="装货客户", field="loadCustomer"),
  1398. ExportColumn(title="装货地址", field="loadAddress"),
  1399. ExportColumn(title="装货时间", field="loadTime"),
  1400. ExportColumn(title="送货客户", field="deliveryCustomer"),
  1401. ExportColumn(title="送货地址", field="deliveryAddress"),
  1402. ExportColumn(title="送货时间", field="deliveryTime"),
  1403. ExportColumn(title="产品名称", field="productName"),
  1404. ExportColumn(title="品牌", field="brandName"),
  1405. ExportColumn(title="别名", field="aliasName"),
  1406. ExportColumn(title="型号/规格", field="model"),
  1407. ExportColumn(title="包装", field="packageSpec"),
  1408. ExportColumn(title="单位", field="unit", dict_type="crm_product_unit"),
  1409. ExportColumn(title="计划量", field="planQty"),
  1410. ExportColumn(title="实际量", field="actualQty"),
  1411. ExportColumn(title="单价(元)", field="unitPrice"),
  1412. ExportColumn(title="金额(元)", field="amount"),
  1413. ExportColumn(title="车牌号", field="licensePlate"),
  1414. ExportColumn(title="运费单位", field="freightType"),
  1415. ExportColumn(title="运费单价", field="freightPrice"),
  1416. ExportColumn(title="运费金额", field="freightAmount"),
  1417. ExportColumn(title="ERP", field="erpEntered"),
  1418. ExportColumn(title="备注", field="remark"),
  1419. ]
  1420. def _load_transports_by_id(db: Session, tenant_no: str, transport_ids: list[str]) -> dict[str, PlTransport]:
  1421. if not transport_ids:
  1422. return {}
  1423. rows = (
  1424. db.query(PlTransport)
  1425. .filter(
  1426. PlTransport.tenant_no == tenant_no,
  1427. PlTransport.del_flag == 0,
  1428. PlTransport.id.in_(transport_ids),
  1429. )
  1430. .all()
  1431. )
  1432. return {row.id: row for row in rows}
  1433. class PlanExportBody(BaseModel):
  1434. q: Optional[str] = None
  1435. plan_no: Optional[str] = Field(default=None, alias="planNo")
  1436. plan_type: Optional[str] = Field(default=None, alias="planType")
  1437. follow_status: Optional[str] = Field(default=None, alias="followStatus")
  1438. exclude_follow_status: Optional[str] = Field(default=None, alias="excludeFollowStatus")
  1439. transport_status: Optional[str] = Field(default=None, alias="transportStatus")
  1440. archive_status: Optional[str] = Field(default=None, alias="archiveStatus")
  1441. customer_name: Optional[str] = Field(default=None, alias="customerName")
  1442. product_name: Optional[str] = Field(default=None, alias="productName")
  1443. package_spec: Optional[str] = Field(default=None, alias="packageSpec")
  1444. license_plate: Optional[str] = Field(default=None, alias="licensePlate")
  1445. plan_date_from: Optional[date] = Field(default=None, alias="planDateFrom")
  1446. plan_date_to: Optional[date] = Field(default=None, alias="planDateTo")
  1447. delivery_time_from: Optional[date] = Field(default=None, alias="deliveryTimeFrom")
  1448. delivery_time_to: Optional[date] = Field(default=None, alias="deliveryTimeTo")
  1449. has_hazard_product: Optional[int] = Field(default=None, alias="hasHazardProduct")
  1450. cert_upload_status: Optional[str] = Field(default=None, alias="certUploadStatus")
  1451. hazard_cert_ready: Optional[int] = Field(default=None, alias="hazardCertReady")
  1452. stock_role: Optional[str] = Field(default=None, alias="stockRole")
  1453. ids: Optional[List[str]] = None
  1454. columns: Optional[List[str]] = None
  1455. export_format: Optional[str] = Field(
  1456. default=None,
  1457. alias="exportFormat",
  1458. description="archive=运输计划归档 Excel;archive_zip=归档数据及附件 ZIP",
  1459. )
  1460. model_config = {"populate_by_name": True, "extra": "allow"}
  1461. @router.post("/export")
  1462. def export_plans(
  1463. body: PlanExportBody,
  1464. db: Session = Depends(get_db),
  1465. user: DbUserContext = Depends(get_db_current_user),
  1466. ):
  1467. """导出计划列表(当前筛选全量,或 ids 已选)。"""
  1468. try:
  1469. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1470. query = db.query(PlPlan).filter(
  1471. PlPlan.tenant_no == tenant_no,
  1472. PlPlan.del_flag == 0,
  1473. )
  1474. if body.q:
  1475. q_kw = body.q.strip()
  1476. if q_kw:
  1477. q_lower = q_kw.lower()
  1478. query = query.filter(
  1479. or_(
  1480. PlPlan.plan_no.contains(q_kw),
  1481. func.lower(PlPlan.supplier_name).contains(q_lower),
  1482. func.lower(PlPlan.customer_name).contains(q_lower),
  1483. func.lower(PlPlan.destination_name).contains(q_lower),
  1484. plan_product_name_exists(db, tenant_no, q_kw),
  1485. plan_plate_match(q_kw),
  1486. )
  1487. )
  1488. query = _apply_follow_status_filter(query, body.follow_status)
  1489. query = _apply_exclude_follow_status_filter(query, body.exclude_follow_status)
  1490. if body.has_hazard_product == 1:
  1491. query = query.filter(plan_has_hazard_product_exists(tenant_no))
  1492. column_filters = _filters_from_params(
  1493. _body_to_params(body), db=db, tenant_no=tenant_no
  1494. )
  1495. if column_filters:
  1496. query = query.filter(*column_filters)
  1497. try:
  1498. query = apply_stock_role_filter(
  1499. query, stock_role=body.stock_role, tenant_no=tenant_no
  1500. )
  1501. except ValueError as exc:
  1502. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1503. if body.ids:
  1504. query = query.filter(PlPlan.id.in_(body.ids))
  1505. query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
  1506. export_params = _body_to_params(body)
  1507. sf, so = parse_sort_params(export_params)
  1508. plans = (
  1509. query.order_by(order_plan(sf, so), PlPlan.id.desc())
  1510. .limit(EXPORT_MAX_ROWS + 1)
  1511. .all()
  1512. )
  1513. date_str = datetime.now().strftime("%Y%m%d")
  1514. export_format = (body.export_format or "").strip().lower()
  1515. is_archive_export = export_format == "archive"
  1516. is_archive_zip = export_format == "archive_zip"
  1517. if len(plans) > EXPORT_MAX_ROWS:
  1518. raise ExportValidationError(f"导出超过 {EXPORT_MAX_ROWS} 条,请缩小筛选范围")
  1519. if is_archive_export or is_archive_zip:
  1520. if not plans:
  1521. raise ExportValidationError("没有可导出的数据")
  1522. transport_ids = [plan.transport_id for plan in plans if plan.transport_id]
  1523. transports_by_id = _load_transports_by_id(db, tenant_no, transport_ids)
  1524. items = build_plan_archive_export_rows(
  1525. db,
  1526. tenant_no,
  1527. plans,
  1528. transports_by_id,
  1529. stop_kind=parse_single_stop_kind(
  1530. _param_get(export_params, "stopKind", "stop_kind"),
  1531. _param_get(export_params, "stopKindIn", "stop_kind_in"),
  1532. ),
  1533. stop_customer_keywords=parse_stop_customer_keywords(
  1534. _param_get(export_params, "stopCustomers", "stop_customers")
  1535. ),
  1536. )
  1537. cols = filter_export_columns(
  1538. PLAN_ARCHIVE_EXPORT_COLUMNS, _normalize_plan_export_columns(body.columns)
  1539. )
  1540. if not user_can_view_archive_prices(db, user):
  1541. cols = [c for c in cols if c.field not in ARCHIVE_PRICE_EXPORT_FIELDS]
  1542. if is_archive_zip:
  1543. from app.services.pl_plan_archive_zip_export import (
  1544. build_archive_zip_streaming_response,
  1545. collect_hazard_cert_items,
  1546. load_plan_products_by_plan_ids,
  1547. merge_plan_attachment_items,
  1548. )
  1549. plan_ids = [p.id for p in plans]
  1550. docs = load_plan_document_attachments_by_plan_ids(
  1551. db, tenant_no=tenant_no, plan_ids=plan_ids
  1552. )
  1553. products_by_plan = load_plan_products_by_plan_ids(
  1554. db, tenant_no=tenant_no, plan_ids=plan_ids
  1555. )
  1556. attachments_by_plan = {
  1557. pid: merge_plan_attachment_items(
  1558. docs.get(pid, []),
  1559. collect_hazard_cert_items(products_by_plan.get(pid, [])),
  1560. )
  1561. for pid in plan_ids
  1562. }
  1563. stamp = datetime.now().strftime("%Y%m%d%H%M")
  1564. return build_archive_zip_streaming_response(
  1565. db=db,
  1566. tenant_no=tenant_no,
  1567. columns=cols,
  1568. rows=items,
  1569. attachments_by_plan=attachments_by_plan,
  1570. stamp=stamp,
  1571. )
  1572. filename_stem = f"计划归档_{date_str}"
  1573. return export_plan_archive_xlsx_response(
  1574. db,
  1575. tenant_no=tenant_no,
  1576. columns=cols,
  1577. rows=items,
  1578. filename_stem=filename_stem,
  1579. )
  1580. else:
  1581. plan_ids = [plan.id for plan in plans]
  1582. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  1583. items = _build_plan_list_export_rows(
  1584. db,
  1585. tenant_no=tenant_no,
  1586. plans=plans,
  1587. stops_by_plan=stops_by_plan,
  1588. )
  1589. cols = filter_export_columns(
  1590. PLAN_EXPORT_COLUMNS, _normalize_plan_export_columns(body.columns)
  1591. )
  1592. sheet_title = _plan_list_export_sheet_title(body.stock_role)
  1593. filename_stem = _plan_list_export_filename_stem(body.stock_role, date_str)
  1594. if not (body.stock_role or "").strip():
  1595. return export_plan_list_xlsx_response(
  1596. db,
  1597. tenant_no=tenant_no,
  1598. sheet_title=sheet_title,
  1599. columns=cols,
  1600. rows=items,
  1601. filename_stem=filename_stem,
  1602. )
  1603. return export_rows_to_xlsx_response(
  1604. db,
  1605. tenant_no=tenant_no,
  1606. sheet_title=sheet_title,
  1607. columns=cols,
  1608. rows=items,
  1609. filename_stem=filename_stem,
  1610. )
  1611. except ExportValidationError as e:
  1612. raise HTTPException(status_code=400, detail=str(e)) from e
  1613. @router.get("/count-by-contract")
  1614. def count_plans_by_contract(
  1615. contract_ids: str = Query(..., alias="contractIds", description="合同ID,逗号分隔"),
  1616. db: Session = Depends(get_db),
  1617. user: DbUserContext = Depends(get_db_current_user),
  1618. ) -> dict:
  1619. from app.models.pl_models import PlPlanContract
  1620. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1621. ids = [item.strip() for item in contract_ids.split(",") if item.strip()]
  1622. if not ids:
  1623. return {"counts": {}}
  1624. # 旧单:主表 contract_id;新单:pl_plan_contract 分侧关联(按计划去重计数)
  1625. legacy_rows = (
  1626. db.query(PlPlan.contract_id, func.count(PlPlan.id))
  1627. .filter(
  1628. PlPlan.tenant_no == tenant_no,
  1629. PlPlan.del_flag == 0,
  1630. PlPlan.contract_id.in_(ids),
  1631. )
  1632. )
  1633. legacy_rows = apply_pl_scope(legacy_rows, PlPlan, user, db, tenant_no).group_by(PlPlan.contract_id).all()
  1634. counts: dict[str, int] = {contract_id: count for contract_id, count in legacy_rows}
  1635. link_q = (
  1636. db.query(PlPlanContract.contract_id, PlPlan.id)
  1637. .join(PlPlan, PlPlan.id == PlPlanContract.plan_id)
  1638. .filter(
  1639. PlPlanContract.tenant_no == tenant_no,
  1640. PlPlanContract.del_flag == 0,
  1641. PlPlanContract.contract_id.in_(ids),
  1642. PlPlan.tenant_no == tenant_no,
  1643. PlPlan.del_flag == 0,
  1644. )
  1645. )
  1646. link_q = apply_pl_scope(link_q, PlPlan, user, db, tenant_no)
  1647. seen: set[tuple[str, str]] = set()
  1648. for contract_id, plan_id in link_q.all():
  1649. key = (contract_id, plan_id)
  1650. if key in seen:
  1651. continue
  1652. seen.add(key)
  1653. # 若旧主表已计过同一计划+合同,不再重复加(新单主表 contract_id 应为空)
  1654. counts[contract_id] = counts.get(contract_id, 0) + 1
  1655. return {"counts": counts}
  1656. @router.get("/contract-product-arrange-qty")
  1657. def get_contract_product_arrange_qty(
  1658. contract_id: str = Query(..., alias="contractId"),
  1659. product_id: str = Query(default="", alias="productId"),
  1660. package_spec: Optional[str] = Query(default=None, alias="packageSpec"),
  1661. exclude_plan_id: Optional[str] = Query(default=None, alias="excludePlanId"),
  1662. contract_product_id: Optional[str] = Query(default=None, alias="contractProductId"),
  1663. db: Session = Depends(get_db),
  1664. user: DbUserContext = Depends(get_db_current_user),
  1665. ) -> dict:
  1666. """表单可安排数量 + 问号弹窗:合同用量与已安排计划列表。"""
  1667. from app.services.pl_contract_plan_qty import get_contract_product_arrange_qty_detail
  1668. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1669. return get_contract_product_arrange_qty_detail(
  1670. db,
  1671. tenant_no=tenant_no,
  1672. contract_id=contract_id,
  1673. product_id=product_id,
  1674. package_spec=package_spec,
  1675. exclude_plan_id=exclude_plan_id,
  1676. contract_product_id=contract_product_id,
  1677. )
  1678. @router.post("/contract-product-arrange-qty/batch")
  1679. def batch_contract_product_arrange_qty(
  1680. body: dict,
  1681. db: Session = Depends(get_db),
  1682. user: DbUserContext = Depends(get_db_current_user),
  1683. ) -> dict:
  1684. """
  1685. 批量可安排数量摘要(不含已安排计划明细)。
  1686. 仅供运输计划新增/编辑/调整运输表单打开时预取,问号弹窗仍走单条详情接口。
  1687. """
  1688. from app.services.pl_contract_plan_qty import batch_contract_product_arrange_qty_summaries
  1689. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1690. items = body.get("items") if isinstance(body, dict) else None
  1691. if not isinstance(items, list):
  1692. items = []
  1693. rows = batch_contract_product_arrange_qty_summaries(
  1694. db, tenant_no=tenant_no, items=items
  1695. )
  1696. return {"items": rows}
  1697. @router.get("/contract-gaps", response_model=PlanContractGapListResponse)
  1698. def list_contract_gaps(
  1699. request: Request,
  1700. page: int = Query(default=1, ge=1),
  1701. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  1702. plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划编号"),
  1703. gap_type: Optional[str] = Query(
  1704. default=None,
  1705. alias="gapType",
  1706. description="缺口类型:supplier | demander | both,多个以英文逗号分隔",
  1707. ),
  1708. customer_keyword: Optional[str] = Query(
  1709. default=None,
  1710. alias="customerKeyword",
  1711. description="供方或需方名称关键词",
  1712. ),
  1713. plan_date_from: Optional[date] = Query(default=None, alias="planDateFrom"),
  1714. plan_date_to: Optional[date] = Query(default=None, alias="planDateTo"),
  1715. delivery_time_from: Optional[date] = Query(default=None, alias="deliveryTimeFrom"),
  1716. delivery_time_to: Optional[date] = Query(default=None, alias="deliveryTimeTo"),
  1717. plan_type: Optional[str] = Query(default=None, alias="planType"),
  1718. follow_status: Optional[str] = Query(default=None, alias="followStatus"),
  1719. q: Optional[str] = Query(
  1720. default=None,
  1721. description="模糊搜索(计划编号/供方名称/需方名称)",
  1722. ),
  1723. db: Session = Depends(get_db),
  1724. user: DbUserContext = Depends(get_db_current_user),
  1725. ) -> PlanContractGapListResponse:
  1726. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1727. params = _merge_query(
  1728. request,
  1729. planNo=plan_no,
  1730. gapType=gap_type,
  1731. customerKeyword=customer_keyword,
  1732. planDateFrom=plan_date_from,
  1733. planDateTo=plan_date_to,
  1734. deliveryTimeFrom=delivery_time_from,
  1735. deliveryTimeTo=delivery_time_to,
  1736. planType=plan_type,
  1737. followStatus=follow_status,
  1738. )
  1739. column_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
  1740. total, items = list_plan_contract_gaps(
  1741. db,
  1742. tenant_no=tenant_no,
  1743. user=user,
  1744. page=page,
  1745. page_size=page_size,
  1746. gap_type=_param_get(params, "gapType", "gapTypeIn", "gap_type"),
  1747. customer_keyword=_param_get(params, "customerKeyword", "customer_keyword"),
  1748. follow_status=_param_get(params, "followStatus", "followStatusIn", "follow_status"),
  1749. q=q,
  1750. sort_field=_param_get(params, "sortField", "sort_field"),
  1751. sort_order=_param_get(params, "sortOrder", "sort_order"),
  1752. column_filters=column_filters,
  1753. )
  1754. return PlanContractGapListResponse(total=total, list=items)
  1755. CONTRACT_GAP_EXPORT_COLUMNS = [
  1756. ExportColumn(title="计划编号", field="planNo"),
  1757. ExportColumn(title="段次", field="stopSegment"),
  1758. ExportColumn(title="计划日期", field="planDate"),
  1759. ExportColumn(title="送货时间", field="deliveryTimes"),
  1760. ExportColumn(title="单据类型", field="planType"),
  1761. ExportColumn(title="跟进状态", field="followStatus"),
  1762. ExportColumn(title="装货客户", field="loadCustomer"),
  1763. ExportColumn(title="送货客户", field="deliveryCustomer"),
  1764. ExportColumn(title="产品名称", field="productName"),
  1765. ExportColumn(title="供方", field="supplierName"),
  1766. ExportColumn(title="需方", field="customerName"),
  1767. ExportColumn(title="缺口类型", field="gapType"),
  1768. ]
  1769. def _contract_gap_export_base(item: Any) -> dict:
  1770. """计划级字段;单据类型、跟进状态沿用列表导出的中文标签。"""
  1771. if hasattr(item, "model_dump"):
  1772. data = item.model_dump(by_alias=True)
  1773. else:
  1774. data = {
  1775. "planNo": getattr(item, "plan_no", None) or getattr(item, "planNo", "") or "",
  1776. "planDate": getattr(item, "plan_date", None)
  1777. if hasattr(item, "plan_date")
  1778. else getattr(item, "planDate", None),
  1779. "planType": getattr(item, "plan_type", None)
  1780. if hasattr(item, "plan_type")
  1781. else getattr(item, "planType", None),
  1782. "followStatus": getattr(item, "follow_status", None)
  1783. if hasattr(item, "follow_status")
  1784. else getattr(item, "followStatus", None),
  1785. "supplierName": getattr(item, "supplier_name", None)
  1786. if hasattr(item, "supplier_name")
  1787. else getattr(item, "supplierName", None),
  1788. "customerName": getattr(item, "customer_name", None)
  1789. if hasattr(item, "customer_name")
  1790. else getattr(item, "customerName", None),
  1791. }
  1792. plan_type = str(data.get("planType") or "")
  1793. follow = str(data.get("followStatus") or "")
  1794. return {
  1795. "planNo": data.get("planNo") or "",
  1796. "planDate": _format_export_date_text(data.get("planDate")),
  1797. "planType": PLAN_TYPE_LABELS.get(plan_type, plan_type),
  1798. "followStatus": FOLLOW_STATUS_LABELS.get(follow, follow),
  1799. "supplierName": data.get("supplierName") or "",
  1800. "customerName": data.get("customerName") or "",
  1801. }
  1802. def _contract_gap_row_gap_type(kind: str, stop: Any) -> str:
  1803. """本行段缺口:装货缺供方合同,送货缺需方合同;无缺口留空。"""
  1804. if stop is None:
  1805. return ""
  1806. customer_name = getattr(stop, "customer_name", None)
  1807. contracts = getattr(stop, "contracts", None) or []
  1808. if not stop_contract_gap(customer_name, len(contracts)):
  1809. return ""
  1810. if kind == "load":
  1811. return "缺供方合同"
  1812. return "缺需方合同"
  1813. def build_contract_gap_export_rows(
  1814. gap_items: Sequence[Any],
  1815. stops_by_plan: dict,
  1816. ) -> list[dict]:
  1817. """把有缺口的计划按段次×产品展开;无缺口侧仍导出,gapType 为空。"""
  1818. rows: list[dict] = []
  1819. for item in gap_items:
  1820. plan_id = str(getattr(item, "id", "") or "")
  1821. load_read, delivery_reads = (stops_by_plan or {}).get(plan_id, (None, []))
  1822. base = _contract_gap_export_base(item)
  1823. for kind, slot_load, slot_delivery, seq, product in _iter_segment_product_slots(
  1824. load_read, delivery_reads
  1825. ):
  1826. row = {
  1827. "planNo": base["planNo"],
  1828. "stopSegment": _format_stop_segment_label(kind, seq),
  1829. "planDate": base["planDate"],
  1830. "deliveryTimes": "",
  1831. "planType": base["planType"],
  1832. "followStatus": base["followStatus"],
  1833. "loadCustomer": "",
  1834. "deliveryCustomer": "",
  1835. "productName": (
  1836. (getattr(product, "product_name", None) or "").strip()
  1837. if product is not None
  1838. else ""
  1839. ),
  1840. "supplierName": base["supplierName"],
  1841. "customerName": base["customerName"],
  1842. "gapType": "",
  1843. }
  1844. if kind == "load":
  1845. row["loadCustomer"] = (
  1846. getattr(slot_load, "customer_name", None) or ""
  1847. ).strip()
  1848. row["gapType"] = _contract_gap_row_gap_type("load", slot_load)
  1849. else:
  1850. row["deliveryCustomer"] = (
  1851. getattr(slot_delivery, "customer_name", None) or ""
  1852. ).strip()
  1853. row["deliveryTimes"] = _format_export_date_text(
  1854. getattr(slot_delivery, "delivery_time", None)
  1855. if slot_delivery is not None
  1856. else None
  1857. )
  1858. row["gapType"] = _contract_gap_row_gap_type("delivery", slot_delivery)
  1859. rows.append(row)
  1860. return rows
  1861. class ContractGapExportBody(BaseModel):
  1862. plan_no: Optional[str] = Field(default=None, alias="planNo")
  1863. gap_type: Optional[str] = Field(default=None, alias="gapType")
  1864. customer_keyword: Optional[str] = Field(default=None, alias="customerKeyword")
  1865. plan_date_from: Optional[date] = Field(default=None, alias="planDateFrom")
  1866. plan_date_to: Optional[date] = Field(default=None, alias="planDateTo")
  1867. delivery_time_from: Optional[date] = Field(default=None, alias="deliveryTimeFrom")
  1868. delivery_time_to: Optional[date] = Field(default=None, alias="deliveryTimeTo")
  1869. q: Optional[str] = None
  1870. ids: Optional[List[str]] = None
  1871. columns: Optional[List[str]] = None
  1872. model_config = {"populate_by_name": True, "extra": "allow"}
  1873. @router.post("/contract-gaps/export")
  1874. def export_contract_gaps(
  1875. body: ContractGapExportBody,
  1876. db: Session = Depends(get_db),
  1877. user: DbUserContext = Depends(get_db_current_user),
  1878. ):
  1879. try:
  1880. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1881. params = _body_to_params(body)
  1882. column_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
  1883. _, items = list_plan_contract_gaps(
  1884. db,
  1885. tenant_no=tenant_no,
  1886. user=user,
  1887. page=1,
  1888. page_size=EXPORT_MAX_ROWS,
  1889. gap_type=_param_get(params, "gapType", "gapTypeIn", "gap_type"),
  1890. customer_keyword=_param_get(params, "customerKeyword", "customer_keyword"),
  1891. follow_status=_param_get(
  1892. params, "followStatus", "followStatusIn", "follow_status"
  1893. ),
  1894. q=_param_get(params, "q"),
  1895. ids=body.ids,
  1896. max_page_size=EXPORT_MAX_ROWS,
  1897. sort_field=_param_get(params, "sortField", "sort_field"),
  1898. sort_order=_param_get(params, "sortOrder", "sort_order"),
  1899. column_filters=column_filters,
  1900. )
  1901. plan_ids = [item.id for item in items]
  1902. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  1903. rows = build_contract_gap_export_rows(items, stops_by_plan)
  1904. cols = filter_export_columns(CONTRACT_GAP_EXPORT_COLUMNS, body.columns)
  1905. date_str = datetime.now().strftime("%Y%m%d")
  1906. return export_rows_to_xlsx_response(
  1907. db, tenant_no=tenant_no, sheet_title="合同完善", columns=cols, rows=rows,
  1908. filename_stem=f"计划合同完善_{date_str}",
  1909. )
  1910. except ExportValidationError as e:
  1911. raise HTTPException(status_code=400, detail=str(e)) from e
  1912. @router.put("/{id}/stop-contracts", response_model=PlanContractGapRead)
  1913. def put_plan_stop_contracts(
  1914. id: str,
  1915. payload: PlanStopContractsUpdateBody,
  1916. db: Session = Depends(get_db),
  1917. user: DbUserContext = Depends(get_db_current_user),
  1918. ) -> PlanContractGapRead:
  1919. """保存某装货/送货段合同关联(合同补全)。"""
  1920. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1921. plan = _get_plan(db, tenant_no, id, user)
  1922. result = update_plan_stop_contracts(
  1923. db,
  1924. tenant_no=tenant_no,
  1925. plan=plan,
  1926. stop_type=payload.stop_type,
  1927. stop_id=payload.stop_id,
  1928. contracts=payload.contracts,
  1929. user_id=user.id,
  1930. dept_id=user.dept_id,
  1931. )
  1932. plan.update_by = user.id
  1933. db.commit()
  1934. return result
  1935. @router.put("/{id}/contracts/{side}", response_model=PlanContractGapRead)
  1936. def put_plan_side_contracts(
  1937. id: str,
  1938. side: str,
  1939. payload: PlanSideContractsUpdateBody,
  1940. db: Session = Depends(get_db),
  1941. user: DbUserContext = Depends(get_db_current_user),
  1942. ) -> PlanContractGapRead:
  1943. """单侧保存计划合同关联(合同补全)。"""
  1944. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1945. plan = _get_plan(db, tenant_no, id, user)
  1946. result = update_plan_side_contracts(
  1947. db,
  1948. tenant_no=tenant_no,
  1949. plan=plan,
  1950. side=side,
  1951. contracts=payload.contracts,
  1952. user_id=user.id,
  1953. dept_id=user.dept_id,
  1954. )
  1955. plan.update_by = user.id
  1956. db.commit()
  1957. return result
  1958. # =============================================================================
  1959. # 创建计划
  1960. # =============================================================================
  1961. @router.post("/", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
  1962. def create_plan(
  1963. payload: PlanCreate,
  1964. db: Session = Depends(get_db),
  1965. user: DbUserContext = Depends(get_db_current_user),
  1966. ) -> PlanRead:
  1967. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1968. from app.services.pl_plan_stop_service import assert_plan_create_child_ids
  1969. assert_plan_create_child_ids(payload)
  1970. # 生成计划编号
  1971. plan_no = _generate_plan_no(db)
  1972. # 单据类型由前端显式选择;未选则存空,不再自动推断为直销单
  1973. plan_type = (payload.plan_type or "").strip()
  1974. plan = PlPlan(
  1975. id=generate_uuid(),
  1976. tenant_no=tenant_no,
  1977. plan_no=plan_no,
  1978. create_date=_resolve_create_date(payload.create_date),
  1979. plan_date=payload.plan_date,
  1980. belongs_company=payload.belongs_company,
  1981. plan_type=plan_type,
  1982. follow_status="pending_arrange",
  1983. transport_status=payload.transport_status or "pending",
  1984. archive_status=payload.archive_status or "unarchived",
  1985. complete_status=payload.complete_status or "no",
  1986. contract_id=payload.contract_id,
  1987. contract_no=payload.contract_no,
  1988. supplier_id=payload.supplier_id,
  1989. supplier_name=payload.supplier_name,
  1990. destination_id=payload.destination_id,
  1991. destination_name=payload.destination_name,
  1992. customer_id=payload.customer_id,
  1993. customer_name=payload.customer_name,
  1994. contact_name=payload.contact_name,
  1995. contact_phone=payload.contact_phone,
  1996. party_a=payload.party_a,
  1997. party_b=payload.party_b,
  1998. contract_start_date=payload.contract_start_date,
  1999. contract_end_date=payload.contract_end_date,
  2000. delivery_method=payload.delivery_method,
  2001. settlement_method=payload.settlement_method,
  2002. has_freight=payload.has_freight,
  2003. pickup_address=payload.pickup_address,
  2004. pickup_lng=payload.pickup_lng,
  2005. pickup_lat=payload.pickup_lat,
  2006. pickup_time=payload.pickup_time,
  2007. pickup_contact=payload.pickup_contact,
  2008. pickup_notes=payload.pickup_notes,
  2009. destination_address=payload.destination_address,
  2010. destination_lng=payload.destination_lng,
  2011. destination_lat=payload.destination_lat,
  2012. delivery_time=payload.delivery_time,
  2013. delivery_contact=payload.delivery_contact,
  2014. destination_notes=payload.destination_notes,
  2015. follow_user_id=payload.follow_user_id or user.id,
  2016. follow_user_name=payload.follow_user_name or user.nick_name,
  2017. remark=payload.remark,
  2018. create_by=user.id,
  2019. create_dept_id=user.dept_id,
  2020. )
  2021. db.add(plan)
  2022. # 添加产品明细
  2023. if payload.products:
  2024. for idx, prod in enumerate(payload.products):
  2025. pp = build_plan_product(
  2026. tenant_no=tenant_no,
  2027. plan_id=plan.id,
  2028. plan_type=plan_type,
  2029. prod=prod,
  2030. order_num=idx,
  2031. user_id=user.id,
  2032. dept_id=user.dept_id,
  2033. )
  2034. db.add(pp)
  2035. plan.total_amount = calc_plan_total_amount(payload.products or [])
  2036. db.commit()
  2037. db.refresh(plan)
  2038. return plan
  2039. @router.post("/create-full", response_model=PlanWithProducts, status_code=status.HTTP_201_CREATED)
  2040. def create_plan_full(
  2041. payload: PlanCreateFull,
  2042. db: Session = Depends(get_db),
  2043. user: DbUserContext = Depends(get_db_current_user),
  2044. ) -> PlanWithProducts:
  2045. """创建运输计划(完整表单:供方/需方 + 产品库产品 + 可选预选车辆,不自动安排运输)"""
  2046. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  2047. from app.services.pl_plan_stop_service import assert_plan_create_child_ids
  2048. assert_plan_create_child_ids(payload)
  2049. fold_mobile_selection_into_web_fields(payload)
  2050. validate_mobile_meta_request(payload, settings.PL_MOBILE_PLAN_PRODUCT_META_ENABLED)
  2051. validate_mobile_product_links(
  2052. payload.products,
  2053. [p for stop in ([payload.load] if payload.load else []) + list(payload.deliveries or []) for p in stop.products],
  2054. creating=True,
  2055. allow_unlinked_draft=bool(payload.mobile_product_meta and getattr(payload, 'save_mode', '') == 'draft'),
  2056. )
  2057. save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal"
  2058. is_draft = save_mode == "draft"
  2059. multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries)
  2060. qty_warnings: list[str] = []
  2061. load_body = None
  2062. delivery_bodies: list = []
  2063. supplier_contracts: list[PlanContractLink] = []
  2064. demander_contracts: list[PlanContractLink] = []
  2065. if multi_stop and payload.load is not None:
  2066. assert_load_delivery_same_products(payload.load, payload.deliveries)
  2067. if is_draft:
  2068. validate_draft_min_fields(
  2069. belongs_company=payload.belongs_company,
  2070. create_date=payload.create_date,
  2071. )
  2072. if multi_stop and payload.load is not None and payload.mobile_product_meta:
  2073. load_body, delivery_bodies = validate_mobile_draft_stops(
  2074. db, tenant_no, payload.load, payload.deliveries, payload.products,
  2075. payload.belongs_company)
  2076. qty_warnings = collect_stop_qty_warnings(payload.products, load_body, delivery_bodies)
  2077. elif multi_stop and payload.load is not None:
  2078. try:
  2079. load_body, delivery_bodies = validate_plan_stops_write(
  2080. db,
  2081. tenant_no,
  2082. payload.load,
  2083. payload.deliveries,
  2084. payload.products,
  2085. payload.belongs_company,
  2086. )
  2087. qty_warnings = collect_stop_qty_warnings(
  2088. payload.products, load_body, delivery_bodies
  2089. )
  2090. except HTTPException as exc:
  2091. if is_belongs_company_contract_error(exc):
  2092. raise
  2093. load_body, delivery_bodies = None, []
  2094. multi_stop = False
  2095. elif multi_stop:
  2096. if payload.mobile_product_meta:
  2097. validate_mobile_stop_sources(
  2098. db, tenant_no, payload.load, payload.deliveries or [], payload.products,
  2099. payload.belongs_company,
  2100. )
  2101. load_body, delivery_bodies = validate_plan_stops_write(
  2102. db,
  2103. tenant_no,
  2104. payload.load,
  2105. payload.deliveries,
  2106. payload.products,
  2107. payload.belongs_company,
  2108. )
  2109. assert_external_stop_products_have_contract_line(load_body, delivery_bodies)
  2110. qty_warnings = collect_stop_qty_warnings(payload.products, load_body, delivery_bodies)
  2111. else:
  2112. validate_create_full(payload)
  2113. supplier_contracts, demander_contracts = validate_plan_party_contracts(
  2114. db,
  2115. tenant_no,
  2116. supplier_id=payload.supplier_id,
  2117. supplier_name=payload.supplier_name,
  2118. customer_id=payload.customer_id,
  2119. customer_name=payload.customer_name,
  2120. supplier_contracts=payload.supplier_contracts,
  2121. demander_contracts=payload.demander_contracts,
  2122. require_products=payload.products,
  2123. )
  2124. load_body = None
  2125. delivery_bodies = []
  2126. plan_no = _generate_plan_no(db)
  2127. # 单据类型由前端显式选择;未选则存空,不再自动推断为直销单
  2128. plan_type = (payload.plan_type or "").strip()
  2129. has_vehicle = bool(payload.transport and payload.transport.vehicle_id)
  2130. demander_name = (payload.customer_name or "").strip() or None
  2131. plan = PlPlan(
  2132. id=generate_uuid(),
  2133. tenant_no=tenant_no,
  2134. plan_no=plan_no,
  2135. create_date=_resolve_create_date(payload.create_date),
  2136. plan_date=payload.plan_date,
  2137. belongs_company=payload.belongs_company,
  2138. plan_type=plan_type,
  2139. follow_status=resolve_follow_status_on_save(
  2140. save_mode=save_mode, current=None, is_create=True
  2141. ),
  2142. transport_status="pending",
  2143. archive_status="unarchived",
  2144. complete_status="no",
  2145. contract_id=None,
  2146. contract_no=None,
  2147. supplier_id=payload.supplier_id,
  2148. supplier_name=payload.supplier_name,
  2149. destination_id=payload.customer_id or payload.destination_id,
  2150. destination_name=demander_name or payload.destination_name,
  2151. customer_id=payload.customer_id,
  2152. customer_name=payload.customer_name,
  2153. contact_name=payload.contact_name,
  2154. contact_phone=payload.contact_phone,
  2155. party_a=payload.party_a,
  2156. party_b=payload.party_b,
  2157. contract_start_date=payload.contract_start_date,
  2158. contract_end_date=payload.contract_end_date,
  2159. delivery_method=payload.delivery_method,
  2160. settlement_method=payload.settlement_method,
  2161. has_freight=payload.has_freight,
  2162. freight_remark=payload.freight_remark,
  2163. pickup_address=payload.pickup_address,
  2164. pickup_lng=payload.pickup_lng,
  2165. pickup_lat=payload.pickup_lat,
  2166. pickup_time=payload.pickup_time,
  2167. pickup_contact=payload.pickup_contact,
  2168. pickup_notes=payload.pickup_notes,
  2169. destination_address=payload.destination_address,
  2170. destination_lng=payload.destination_lng,
  2171. destination_lat=payload.destination_lat,
  2172. delivery_time=payload.delivery_time,
  2173. delivery_contact=payload.delivery_contact,
  2174. destination_notes=payload.destination_notes,
  2175. total_amount=calc_plan_total_amount(payload.products or []),
  2176. follow_user_id=payload.follow_user_id or user.id,
  2177. follow_user_name=payload.follow_user_name or user.nick_name,
  2178. remark=payload.remark,
  2179. create_by=user.id,
  2180. create_dept_id=user.dept_id,
  2181. )
  2182. db.add(plan)
  2183. db.flush()
  2184. if multi_stop and load_body is not None:
  2185. sync_plan_legacy_from_stops(plan, load_body, delivery_bodies)
  2186. plan_products: list[PlPlanProduct] = []
  2187. for idx, prod in enumerate(payload.products or []):
  2188. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  2189. db,
  2190. tenant_no,
  2191. prod.product_id,
  2192. )
  2193. prod_payload = prod.model_copy(update={"is_toxic": is_toxic, "is_explosive": is_explosive})
  2194. pp = build_plan_product(
  2195. tenant_no=tenant_no,
  2196. plan_id=plan.id,
  2197. plan_type=plan_type,
  2198. prod=prod_payload,
  2199. order_num=idx,
  2200. user_id=user.id,
  2201. dept_id=user.dept_id,
  2202. )
  2203. db.add(pp)
  2204. plan_products.append(pp)
  2205. if multi_stop and load_body is not None:
  2206. replace_plan_stops(
  2207. db,
  2208. tenant_no=tenant_no,
  2209. plan_id=plan.id,
  2210. load=load_body,
  2211. deliveries=delivery_bodies,
  2212. user_id=user.id,
  2213. dept_id=user.dept_id,
  2214. plan_products=plan_products,
  2215. allow_unlinked_draft=bool(payload.mobile_product_meta and is_draft),
  2216. preserve_pending_draft=is_draft,
  2217. )
  2218. safe_upsert_prefs_from_plan_stops(
  2219. db,
  2220. tenant_no=tenant_no,
  2221. load=load_body,
  2222. deliveries=delivery_bodies,
  2223. user_id=user.id,
  2224. dept_id=user.dept_id,
  2225. )
  2226. else:
  2227. replace_plan_contracts(
  2228. db,
  2229. tenant_no=tenant_no,
  2230. plan_id=plan.id,
  2231. supplier_contracts=supplier_contracts,
  2232. demander_contracts=demander_contracts,
  2233. user_id=user.id,
  2234. dept_id=user.dept_id,
  2235. )
  2236. if has_vehicle and payload.transport:
  2237. apply_vehicle_prefill_to_plan(
  2238. db,
  2239. tenant_no=tenant_no,
  2240. plan=plan,
  2241. transport_payload=payload.transport,
  2242. products=plan_products,
  2243. user_id=user.id,
  2244. dept_id=user.dept_id,
  2245. )
  2246. db.commit()
  2247. db.refresh(plan)
  2248. result = PlanWithProducts.model_validate(plan)
  2249. result.products = plan_products
  2250. result.supplier_contracts = supplier_contracts
  2251. result.demander_contracts = demander_contracts
  2252. result.warnings = qty_warnings
  2253. stops = load_plan_stops_grouped(db, tenant_no, [plan.id]).get(plan.id, (None, []))
  2254. attach_plan_stops_to_item(result, plan, stops)
  2255. return result
  2256. # =============================================================================
  2257. # 从合同创建计划
  2258. # =============================================================================
  2259. @router.post("/create-from-contract", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
  2260. def create_from_contract(
  2261. payload: CreateFromContractRequest,
  2262. db: Session = Depends(get_db),
  2263. user: DbUserContext = Depends(get_db_current_user),
  2264. ) -> PlanRead:
  2265. """从 CRM 合同发起计划"""
  2266. from app.models.crm_models import CrmContract, CrmContractProduct
  2267. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  2268. # 查询合同信息
  2269. contract = (
  2270. db.query(CrmContract)
  2271. .filter(CrmContract.id == payload.contract_id, CrmContract.del_flag == 0)
  2272. .first()
  2273. )
  2274. if not contract:
  2275. raise HTTPException(status_code=404, detail="合同不存在")
  2276. assert_contract_selectable_for_plan(db, contract.id, tenant_no)
  2277. # 生成计划编号
  2278. plan_no = _generate_plan_no(db)
  2279. # 自动判断单据类型
  2280. plan_type = _determine_plan_type(contract.supplier_name, contract.customer_name)
  2281. plan = PlPlan(
  2282. id=generate_uuid(),
  2283. tenant_no=tenant_no,
  2284. plan_no=plan_no,
  2285. create_date=_resolve_create_date(payload.create_date),
  2286. plan_date=payload.plan_date,
  2287. belongs_company=contract.belongs_company or "shanxia",
  2288. plan_type=plan_type,
  2289. follow_status="pending_arrange",
  2290. transport_status="pending",
  2291. archive_status="unarchived",
  2292. complete_status="no",
  2293. contract_id=contract.id,
  2294. contract_no=contract.contract_no,
  2295. supplier_id=contract.supplier_id,
  2296. supplier_name=(contract.party_a or contract.supplier_name or "").strip() or None,
  2297. destination_id=contract.customer_id,
  2298. # 客户名称严格跟合同「客户名称」;目的地名称与客户保持一致(不再取需方可章名 party_b)
  2299. destination_name=(contract.customer_name or "").strip() or None,
  2300. customer_id=contract.customer_id,
  2301. customer_name=(contract.customer_name or "").strip() or None,
  2302. contact_name=contract.contact_name,
  2303. contact_phone=contract.contact_phone,
  2304. party_a=contract.party_a,
  2305. party_b=contract.party_b,
  2306. contract_start_date=contract.start_date,
  2307. contract_end_date=contract.end_date,
  2308. delivery_method=contract.delivery_method,
  2309. settlement_method=contract.settlement_method,
  2310. has_freight=1 if contract.settlement_method and "含运" in contract.settlement_method else 0,
  2311. follow_user_id=payload.follow_user_id or user.id,
  2312. follow_user_name=payload.follow_user_name or user.nick_name,
  2313. create_by=user.id,
  2314. create_dept_id=user.dept_id,
  2315. )
  2316. db.add(plan)
  2317. # 查询合同产品明细
  2318. contract_products = (
  2319. db.query(CrmContractProduct)
  2320. .filter(
  2321. CrmContractProduct.contract_id == contract.id,
  2322. CrmContractProduct.del_flag == 0,
  2323. )
  2324. .all()
  2325. )
  2326. # 添加产品明细
  2327. for idx, cp in enumerate(contract_products):
  2328. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  2329. db,
  2330. tenant_no,
  2331. cp.product_id,
  2332. )
  2333. pp = PlPlanProduct(
  2334. id=generate_uuid(),
  2335. tenant_no=tenant_no,
  2336. plan_id=plan.id,
  2337. product_id=cp.product_id,
  2338. product_no=cp.product_no,
  2339. product_name=cp.product_name,
  2340. brand_mode=cp.brand_mode,
  2341. brand_id=cp.brand_id,
  2342. brand_name=cp.brand_name,
  2343. model=cp.model,
  2344. unit=cp.unit or "吨",
  2345. is_explosive=is_explosive,
  2346. is_toxic=is_toxic,
  2347. inbound_estimate_qty=cp.quantity,
  2348. outbound_estimate_qty=cp.quantity,
  2349. order_num=idx,
  2350. create_by=user.id,
  2351. create_dept_id=user.dept_id,
  2352. )
  2353. db.add(pp)
  2354. db.commit()
  2355. db.refresh(plan)
  2356. return plan
  2357. # =============================================================================
  2358. # 获取计划详情
  2359. # =============================================================================
  2360. @router.get("/mobile-product-capability")
  2361. def get_mobile_product_capability(
  2362. user: DbUserContext = Depends(get_db_current_user),
  2363. ):
  2364. return {"mobileProductMetaVersion": 1}
  2365. @router.get("/{id}", response_model=PlanWithProducts)
  2366. def get_plan(
  2367. id: str,
  2368. db: Session = Depends(get_db),
  2369. user: DbUserContext = Depends(get_db_current_user),
  2370. include_mobile_product_meta: Annotated[bool, Query(alias="includeMobileProductMeta")] = False,
  2371. ) -> PlanWithProducts:
  2372. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  2373. plan = _get_plan(db, tenant_no, id, user)
  2374. products = (
  2375. db.query(PlPlanProduct)
  2376. .filter(
  2377. PlPlanProduct.plan_id == id,
  2378. PlPlanProduct.del_flag == 0,
  2379. )
  2380. .order_by(PlPlanProduct.order_num)
  2381. .all()
  2382. )
  2383. hazard_map = fetch_hazard_flags_map(
  2384. db,
  2385. tenant_no,
  2386. [product.product_id for product in products if product.product_id],
  2387. )
  2388. brand_qtys_by_pp = load_brand_qtys_by_plan_product_ids(
  2389. db,
  2390. tenant_no=tenant_no,
  2391. plan_product_ids=[p.id for p in products],
  2392. )
  2393. enriched_products = []
  2394. for product in products:
  2395. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  2396. db,
  2397. tenant_no,
  2398. product.product_id,
  2399. hazard_map=hazard_map,
  2400. )
  2401. item = PlanProductRead.model_validate(product)
  2402. raw_brand_qtys = brand_qtys_by_pp.get(product.id) or None
  2403. brand_qtys = (
  2404. [PlanArchiveBrandQtyItem.model_validate(x) for x in raw_brand_qtys]
  2405. if raw_brand_qtys
  2406. else None
  2407. )
  2408. item = item.model_copy(
  2409. update={
  2410. "is_toxic": is_toxic,
  2411. "is_explosive": is_explosive,
  2412. "brand_qtys": brand_qtys,
  2413. }
  2414. )
  2415. enriched_products.append(item)
  2416. result = PlanWithProducts.model_validate(plan)
  2417. result.products = enriched_products
  2418. result.execution_lock_reason = plan_execution_lock_reason(db, tenant_no, id)
  2419. result.execution_started = bool(result.execution_lock_reason)
  2420. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  2421. db, tenant_no, [id]
  2422. ).get(id, ([], []))
  2423. result.supplier_contracts = supplier_contracts
  2424. result.demander_contracts = demander_contracts
  2425. stops = load_plan_stops_grouped(db, tenant_no, [id]).get(id, (None, []))
  2426. attach_plan_stops_to_item(result, plan, stops)
  2427. if include_mobile_product_meta:
  2428. result.mobile_product_meta_version = 1
  2429. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  2430. db,
  2431. tenant_no=tenant_no,
  2432. product_ids=[product.product_id for product in result.products or [] if product.product_id],
  2433. )
  2434. attach_web_selection_as_mobile_meta(result, standard_by_id, alias_options_by_id)
  2435. if result.follow_status == "drafting":
  2436. for stop in ([result.load] if result.load else []) + list(result.deliveries or []):
  2437. for product in stop.products:
  2438. product.unlinked_draft = not bool(product.plan_product_id)
  2439. can_price = user_can_view_archive_prices(db, user)
  2440. return mask_plan_with_products_prices(result, enabled=not can_price)
  2441. # =============================================================================
  2442. # 更新计划
  2443. # =============================================================================
  2444. @router.put("/{id}", response_model=PlanWithProducts)
  2445. def update_plan(
  2446. id: str,
  2447. payload: PlanUpdate,
  2448. db: Session = Depends(get_db),
  2449. user: DbUserContext = Depends(get_db_current_user),
  2450. ) -> PlanWithProducts:
  2451. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  2452. plan = _get_plan(db, tenant_no, id, user)
  2453. if plan.follow_status == "cancelled":
  2454. raise HTTPException(status_code=400, detail="已作废的计划不能修改")
  2455. fold_mobile_selection_into_web_fields(payload)
  2456. validate_mobile_meta_request(payload, settings.PL_MOBILE_PLAN_PRODUCT_META_ENABLED)
  2457. validate_mobile_product_links(
  2458. payload.products,
  2459. [p for stop in ([payload.load] if payload.load else []) + list(payload.deliveries or []) for p in stop.products],
  2460. allow_unlinked_draft=bool(payload.mobile_product_meta and getattr(payload, 'save_mode', '') == 'draft'),
  2461. )
  2462. from app.services.pl_plan_stop_service import (
  2463. preflight_plan_child_update, complete_plan_stops_for_validation,
  2464. )
  2465. prepared_product_ids, prepared_stop_ids = preflight_plan_child_update(db, tenant_no, id, payload)
  2466. save_mode = (getattr(payload, "save_mode", None) or "formal").strip() or "formal"
  2467. is_draft = save_mode == "draft"
  2468. if is_draft:
  2469. assert_can_save_as_draft(plan.follow_status)
  2470. validate_draft_min_fields(
  2471. belongs_company=(
  2472. payload.belongs_company
  2473. if payload.belongs_company is not None
  2474. else plan.belongs_company
  2475. ),
  2476. create_date=(
  2477. payload.create_date if payload.create_date is not None else plan.create_date
  2478. ),
  2479. )
  2480. multi_stop = uses_multi_stop_payload(payload.load, payload.deliveries)
  2481. validation_load, validation_deliveries = (payload.load, payload.deliveries)
  2482. if multi_stop:
  2483. validation_load, validation_deliveries = complete_plan_stops_for_validation(
  2484. db, tenant_no, id, payload.load, payload.deliveries)
  2485. if validation_load is not None:
  2486. assert_load_delivery_same_products(validation_load, validation_deliveries)
  2487. qty_warnings: list[str] = []
  2488. load_body: Optional[PlanLoadBody] = None
  2489. delivery_bodies: list[PlanDeliveryBody] = []
  2490. if not is_draft and has_pending_stop_products(db, tenant_no, id):
  2491. if payload.load is None or any(
  2492. not p.plan_product_id and not getattr(p, 'plan_product_client_key', None)
  2493. for stop in [payload.load] + list(payload.deliveries or []) for p in stop.products
  2494. ):
  2495. raise HTTPException(422, '草稿含待关联产品,请补齐关联后正式保存')
  2496. # 更新主表字段(跟进状态仅由业务动作 / saveMode 决议维护,忽略客户端 follow_status)
  2497. update_data = payload.model_dump(
  2498. exclude_unset=True,
  2499. exclude={
  2500. "mobile_product_meta",
  2501. "products",
  2502. "transport",
  2503. "follow_status",
  2504. "save_mode",
  2505. "supplier_contracts",
  2506. "demander_contracts",
  2507. "load",
  2508. "deliveries",
  2509. },
  2510. )
  2511. for key, value in update_data.items():
  2512. setattr(plan, key, value)
  2513. # 新模型:单一合同字段不再作为写入源
  2514. if "supplier_contracts" in payload.model_fields_set or "demander_contracts" in payload.model_fields_set:
  2515. plan.contract_id = None
  2516. plan.contract_no = None
  2517. if payload.customer_id is not None or payload.customer_name is not None:
  2518. plan.destination_id = plan.customer_id
  2519. plan.destination_name = plan.customer_name
  2520. supplier_id = plan.supplier_id
  2521. supplier_name = plan.supplier_name
  2522. customer_id = plan.customer_id
  2523. customer_name = plan.customer_name
  2524. if is_draft:
  2525. supplier_contracts: list[PlanContractLink] = []
  2526. demander_contracts: list[PlanContractLink] = []
  2527. if multi_stop and validation_load is not None and payload.mobile_product_meta:
  2528. load_body, delivery_bodies = validate_mobile_draft_stops(
  2529. db, tenant_no, validation_load, validation_deliveries, payload.products,
  2530. payload.belongs_company if payload.belongs_company is not None else plan.belongs_company)
  2531. qty_warnings = collect_stop_qty_warnings(payload.products, load_body, delivery_bodies)
  2532. sync_plan_legacy_from_stops(plan, load_body, delivery_bodies)
  2533. elif multi_stop and validation_load is not None:
  2534. try:
  2535. load_body, delivery_bodies = validate_plan_stops_write(
  2536. db,
  2537. tenant_no,
  2538. validation_load,
  2539. validation_deliveries,
  2540. payload.products,
  2541. payload.belongs_company if payload.belongs_company is not None else plan.belongs_company,
  2542. )
  2543. products_for_warn = payload.products if payload.products is not None else None
  2544. qty_warnings = collect_stop_qty_warnings(
  2545. products_for_warn, load_body, delivery_bodies
  2546. )
  2547. sync_plan_legacy_from_stops(plan, load_body, delivery_bodies)
  2548. except HTTPException as exc:
  2549. if is_belongs_company_contract_error(exc):
  2550. raise
  2551. # 跳过段更新,保留库内原段
  2552. load_body, delivery_bodies = None, []
  2553. multi_stop = False
  2554. if not multi_stop:
  2555. supplier_contracts, demander_contracts = load_plan_contracts_grouped(
  2556. db, tenant_no, [id]
  2557. ).get(id, ([], []))
  2558. elif multi_stop:
  2559. if payload.mobile_product_meta:
  2560. validate_mobile_stop_sources(
  2561. db, tenant_no, validation_load, validation_deliveries or [], payload.products,
  2562. payload.belongs_company if payload.belongs_company is not None else plan.belongs_company,
  2563. )
  2564. load_body, delivery_bodies = validate_plan_stops_write(
  2565. db,
  2566. tenant_no,
  2567. validation_load,
  2568. validation_deliveries,
  2569. payload.products,
  2570. payload.belongs_company if payload.belongs_company is not None else plan.belongs_company,
  2571. )
  2572. assert_external_stop_products_have_contract_line(load_body, delivery_bodies)
  2573. products_for_warn = payload.products if payload.products is not None else None
  2574. qty_warnings = collect_stop_qty_warnings(products_for_warn, load_body, delivery_bodies)
  2575. sync_plan_legacy_from_stops(plan, load_body, delivery_bodies)
  2576. supplier_contracts = []
  2577. demander_contracts = []
  2578. else:
  2579. supplier_contracts_in = (
  2580. payload.supplier_contracts
  2581. if "supplier_contracts" in payload.model_fields_set
  2582. else load_plan_contracts_grouped(db, tenant_no, [id]).get(id, ([], []))[0]
  2583. )
  2584. demander_contracts_in = (
  2585. payload.demander_contracts
  2586. if "demander_contracts" in payload.model_fields_set
  2587. else load_plan_contracts_grouped(db, tenant_no, [id]).get(id, ([], []))[1]
  2588. )
  2589. # 编辑保存若带了产品或任一侧合同字段,按新规则完整校验供方/需方
  2590. if (
  2591. payload.products is not None
  2592. or "supplier_contracts" in payload.model_fields_set
  2593. or "demander_contracts" in payload.model_fields_set
  2594. or payload.supplier_id is not None
  2595. or payload.customer_id is not None
  2596. ):
  2597. supplier_contracts, demander_contracts = validate_plan_party_contracts(
  2598. db,
  2599. tenant_no,
  2600. supplier_id=supplier_id,
  2601. supplier_name=supplier_name,
  2602. customer_id=customer_id,
  2603. customer_name=customer_name,
  2604. supplier_contracts=supplier_contracts_in,
  2605. demander_contracts=demander_contracts_in,
  2606. require_products=payload.products if payload.products is not None else None,
  2607. )
  2608. replace_plan_contracts(
  2609. db,
  2610. tenant_no=tenant_no,
  2611. plan_id=id,
  2612. supplier_contracts=supplier_contracts,
  2613. demander_contracts=demander_contracts,
  2614. user_id=user.id,
  2615. dept_id=user.dept_id,
  2616. )
  2617. else:
  2618. supplier_contracts, demander_contracts = load_plan_contracts_grouped(db, tenant_no, [id]).get(
  2619. id, ([], [])
  2620. )
  2621. plan_type = payload.plan_type or plan.plan_type
  2622. # 更新产品明细(按 id upsert,保持 pl_plan_product.id 稳定)
  2623. if payload.products is not None:
  2624. existing_all = (
  2625. db.query(PlPlanProduct)
  2626. .filter(
  2627. PlPlanProduct.plan_id == id,
  2628. PlPlanProduct.tenant_no == tenant_no,
  2629. )
  2630. .all()
  2631. )
  2632. existing_all_by_id = {row.id: row for row in existing_all if row.del_flag == 0}
  2633. existing_products = [row for row in existing_all if row.del_flag == 0]
  2634. old_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, existing_products)
  2635. cert_by_product_id = {
  2636. row.product_id: (
  2637. row.purchase_cert_url,
  2638. row.transport_cert_url,
  2639. row.legal_use_cert_url,
  2640. )
  2641. for row in existing_products
  2642. }
  2643. kept_ids: set[str] = set()
  2644. plan_products: list[PlPlanProduct] = []
  2645. for idx, prod in enumerate(payload.products):
  2646. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  2647. db,
  2648. tenant_no,
  2649. prod.product_id,
  2650. )
  2651. purchase_cert = prod.purchase_cert_url
  2652. transport_cert = prod.transport_cert_url
  2653. legal_use_cert = prod.legal_use_cert_url
  2654. old_certs = cert_by_product_id.get(prod.product_id, (None, None, None))
  2655. old_purchase, old_transport, old_legal_use = old_certs
  2656. purchase_cert = purchase_cert or old_purchase
  2657. transport_cert = transport_cert or old_transport
  2658. legal_use_cert = legal_use_cert or old_legal_use
  2659. prod_payload = prod.model_copy(
  2660. update={
  2661. "is_toxic": is_toxic,
  2662. "is_explosive": is_explosive,
  2663. "purchase_cert_url": purchase_cert,
  2664. "transport_cert_url": transport_cert,
  2665. "legal_use_cert_url": legal_use_cert,
  2666. }
  2667. )
  2668. row_id = prepared_product_ids[idx]
  2669. existing = existing_all_by_id.get(row_id)
  2670. if existing is not None:
  2671. apply_plan_product_fields(
  2672. existing,
  2673. plan_type=plan_type,
  2674. prod=prod_payload,
  2675. order_num=idx,
  2676. user_id=user.id,
  2677. )
  2678. kept_ids.add(existing.id)
  2679. plan_products.append(existing)
  2680. else:
  2681. pp = build_plan_product(
  2682. tenant_no=tenant_no,
  2683. plan_id=id,
  2684. plan_type=plan_type,
  2685. prod=prod_payload,
  2686. order_num=idx,
  2687. user_id=user.id,
  2688. dept_id=user.dept_id,
  2689. product_row_id=row_id,
  2690. )
  2691. db.add(pp)
  2692. kept_ids.add(pp.id)
  2693. plan_products.append(pp)
  2694. for row in existing_products:
  2695. if row.id not in kept_ids:
  2696. row.del_flag = 1
  2697. row.update_by = user.id
  2698. new_hazard_product_ids = collect_hazard_product_ids(db, tenant_no, payload.products)
  2699. if old_hazard_product_ids != new_hazard_product_ids:
  2700. clear_hazard_cert_ready(plan)
  2701. plan.total_amount = calc_plan_total_amount(plan_products)
  2702. else:
  2703. plan_products = (
  2704. db.query(PlPlanProduct)
  2705. .filter(
  2706. PlPlanProduct.plan_id == id,
  2707. PlPlanProduct.del_flag == 0,
  2708. )
  2709. .all()
  2710. )
  2711. # 同步运输预选 / 关联运输单运费
  2712. if payload.transport is not None:
  2713. if payload.transport.vehicle_id:
  2714. sync_transport_prefill_for_plan(
  2715. db,
  2716. tenant_no=tenant_no,
  2717. plan=plan,
  2718. transport_payload=payload.transport,
  2719. products=plan_products,
  2720. user_id=user.id,
  2721. dept_id=user.dept_id,
  2722. )
  2723. else:
  2724. clear_draft_transport_for_plan(
  2725. db,
  2726. tenant_no=tenant_no,
  2727. plan=plan,
  2728. user_id=user.id,
  2729. )
  2730. if multi_stop and load_body is not None:
  2731. # Validation uses a complete read-only view; persistence receives only submitted collections.
  2732. if payload.load is not None and payload.load.contracts is not None:
  2733. payload.load.contracts = load_body.contracts
  2734. for partial, validated in zip(payload.deliveries or [], delivery_bodies):
  2735. if partial.contracts is not None:
  2736. partial.contracts = validated.contracts
  2737. replace_plan_stops(
  2738. db,
  2739. tenant_no=tenant_no,
  2740. plan_id=id,
  2741. load=payload.load,
  2742. deliveries=payload.deliveries,
  2743. user_id=user.id,
  2744. dept_id=user.dept_id,
  2745. plan_products=plan_products,
  2746. allow_unlinked_draft=bool(payload.mobile_product_meta and is_draft),
  2747. preserve_pending_draft=is_draft,
  2748. resolved_stop_ids=prepared_stop_ids,
  2749. )
  2750. safe_upsert_prefs_from_plan_stops(
  2751. db,
  2752. tenant_no=tenant_no,
  2753. load=load_body,
  2754. deliveries=delivery_bodies,
  2755. user_id=user.id,
  2756. dept_id=user.dept_id,
  2757. )
  2758. plan.update_by = user.id
  2759. plan.follow_status = resolve_follow_status_on_save(
  2760. save_mode=save_mode,
  2761. current=plan.follow_status,
  2762. is_create=False,
  2763. )
  2764. dibang_date_warnings = sync_linked_dibang_dates_for_plan(
  2765. db,
  2766. tenant_no=tenant_no,
  2767. plan=plan,
  2768. user_id=user.id,
  2769. dept_id=user.dept_id or "",
  2770. )
  2771. dibang_upsert_warnings: list[str] = []
  2772. if (
  2773. plan.transport_status == "arranged"
  2774. and plan.transport_id
  2775. and multi_stop
  2776. and load_body is not None
  2777. ):
  2778. transport_row = (
  2779. db.query(PlTransport)
  2780. .filter(
  2781. PlTransport.id == plan.transport_id,
  2782. PlTransport.tenant_no == tenant_no,
  2783. PlTransport.del_flag == 0,
  2784. )
  2785. .first()
  2786. )
  2787. if transport_row:
  2788. stop_options: list[StopDibangOption] = []
  2789. load_row = (
  2790. db.query(PlPlanLoad)
  2791. .filter(
  2792. PlPlanLoad.plan_id == id,
  2793. PlPlanLoad.tenant_no == tenant_no,
  2794. PlPlanLoad.del_flag == 0,
  2795. )
  2796. .first()
  2797. )
  2798. if load_row:
  2799. stop_options.append(
  2800. StopDibangOption(
  2801. stop_type="load",
  2802. stop_id=load_row.id,
  2803. generate_dibang=bool(load_row.generate_dibang),
  2804. )
  2805. )
  2806. delivery_rows = (
  2807. db.query(PlPlanDelivery)
  2808. .filter(
  2809. PlPlanDelivery.plan_id == id,
  2810. PlPlanDelivery.tenant_no == tenant_no,
  2811. PlPlanDelivery.del_flag == 0,
  2812. )
  2813. .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
  2814. .all()
  2815. )
  2816. for delivery_row in delivery_rows:
  2817. stop_options.append(
  2818. StopDibangOption(
  2819. stop_type="delivery",
  2820. stop_id=delivery_row.id,
  2821. generate_dibang=bool(delivery_row.generate_dibang),
  2822. )
  2823. )
  2824. if stop_options:
  2825. try:
  2826. _link_results, dibang_upsert_warnings = upsert_stop_dibang_links(
  2827. db,
  2828. tenant_no=tenant_no,
  2829. plan=plan,
  2830. transport=transport_row,
  2831. stop_options=stop_options,
  2832. user_id=user.id,
  2833. dept_id=user.dept_id or "",
  2834. )
  2835. except ValueError as exc:
  2836. db.rollback()
  2837. raise HTTPException(status_code=400, detail=str(exc)) from exc
  2838. db.commit()
  2839. db.refresh(plan)
  2840. result = PlanWithProducts.model_validate(plan)
  2841. result.products = plan_products
  2842. result.supplier_contracts = supplier_contracts
  2843. result.demander_contracts = demander_contracts
  2844. result.warnings = [*qty_warnings, *dibang_date_warnings, *dibang_upsert_warnings]
  2845. stops = load_plan_stops_grouped(db, tenant_no, [id]).get(id, (None, []))
  2846. attach_plan_stops_to_item(result, plan, stops)
  2847. return result
  2848. @router.patch("/{id}/product-certs", response_model=PlanWithProducts)
  2849. def patch_plan_product_certs(
  2850. id: str,
  2851. payload: PlanProductCertsUpdate,
  2852. db: Session = Depends(get_db),
  2853. user: DbUserContext = Depends(get_db_current_user),
  2854. ) -> PlanWithProducts:
  2855. """更新计划危化品产品证书(购买证/运输证/合法使用证明,不触发安排运输)"""
  2856. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  2857. _get_plan(db, tenant_no, id, user)
  2858. update_plan_product_certs(
  2859. db,
  2860. tenant_no=tenant_no,
  2861. plan_id=id,
  2862. items=payload.products,
  2863. user_id=user.id,
  2864. )
  2865. plan = _get_plan(db, tenant_no, id, user)
  2866. products = (
  2867. db.query(PlPlanProduct)
  2868. .filter(
  2869. PlPlanProduct.plan_id == id,
  2870. PlPlanProduct.del_flag == 0,
  2871. )
  2872. .order_by(PlPlanProduct.order_num)
  2873. .all()
  2874. )
  2875. result = PlanWithProducts.model_validate(plan)
  2876. result.products = products
  2877. return result
  2878. @router.patch("/{id}/hazard-cert-ready", response_model=PlanWithProducts)
  2879. def patch_plan_hazard_cert_ready(
  2880. id: str,
  2881. payload: HazardCertReadyBody,
  2882. db: Session = Depends(get_db),
  2883. user: DbUserContext = Depends(get_db_current_user),
  2884. ) -> PlanWithProducts:
  2885. """标记或取消危化品证书齐备(人工确认)"""
  2886. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  2887. _get_plan(db, tenant_no, id, user)
  2888. set_hazard_cert_ready(
  2889. db,
  2890. tenant_no=tenant_no,
  2891. plan_id=id,
  2892. ready=payload.ready,
  2893. user_id=user.id,
  2894. )
  2895. plan = _get_plan(db, tenant_no, id, user)
  2896. products = (
  2897. db.query(PlPlanProduct)
  2898. .filter(
  2899. PlPlanProduct.plan_id == id,
  2900. PlPlanProduct.del_flag == 0,
  2901. )
  2902. .order_by(PlPlanProduct.order_num)
  2903. .all()
  2904. )
  2905. result = PlanWithProducts.model_validate(plan)
  2906. result.products = products
  2907. return result
  2908. @router.patch("/{id}/erp-entered", response_model=PlanWithProducts)
  2909. def patch_plan_erp_entered(
  2910. id: str,
  2911. payload: ErpEnteredBody,
  2912. db: Session = Depends(get_db),
  2913. user: DbUserContext = Depends(get_db_current_user),
  2914. ) -> PlanWithProducts:
  2915. """标记或取消运输计划已录入 ERP。"""
  2916. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  2917. set_plan_erp_entered(
  2918. db,
  2919. tenant_no=tenant_no,
  2920. plan_id=id,
  2921. entered=payload.entered,
  2922. user_id=user.id,
  2923. )
  2924. plan = _get_plan(db, tenant_no, id, user)
  2925. products = (
  2926. db.query(PlPlanProduct)
  2927. .filter(
  2928. PlPlanProduct.plan_id == id,
  2929. PlPlanProduct.del_flag == 0,
  2930. )
  2931. .order_by(PlPlanProduct.order_num)
  2932. .all()
  2933. )
  2934. result = PlanWithProducts.model_validate(plan)
  2935. result.products = products
  2936. result.document_attachments = load_plan_document_attachments_by_plan_ids(
  2937. db,
  2938. tenant_no=tenant_no,
  2939. plan_ids=[id],
  2940. ).get(id, [])
  2941. return result
  2942. def _build_driver_url(token: str) -> str:
  2943. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  2944. return f"{origin}/#/public/pl/transport/{token}"
  2945. @router.get("/{id}/arrange-info", response_model=PlanArrangeInfoRead)
  2946. def get_plan_arrange_info(
  2947. id: str,
  2948. db: Session = Depends(get_db),
  2949. user: DbUserContext = Depends(get_db_current_user),
  2950. ) -> PlanArrangeInfoRead:
  2951. """获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。"""
  2952. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  2953. plan = _get_plan(db, tenant_no, id, user)
  2954. empty = PlanArrangeInfoRead(
  2955. driver_url=None,
  2956. license_plate=None,
  2957. site_records=[],
  2958. inventory_records=[],
  2959. requires_site_link=False,
  2960. )
  2961. if plan.follow_status == "cancelled" or plan.transport_status != "arranged" or not plan.transport_id:
  2962. return empty
  2963. transport = (
  2964. db.query(PlTransport)
  2965. .filter(
  2966. PlTransport.id == plan.transport_id,
  2967. PlTransport.tenant_no == tenant_no,
  2968. PlTransport.del_flag == 0,
  2969. )
  2970. .first()
  2971. )
  2972. if not transport or transport.transport_status == "cancelled":
  2973. return empty
  2974. token = (transport.driver_access_token or "").strip()
  2975. driver_url = _build_driver_url(token) if token else None
  2976. requires_site_link = plan_requires_site_link(db, tenant_no=tenant_no, plan=plan)
  2977. site_records = build_site_records_for_arranged_plan(
  2978. db,
  2979. tenant_no=tenant_no,
  2980. plan=plan,
  2981. transport=transport,
  2982. )
  2983. links = (
  2984. db.query(PlTransportDbLink)
  2985. .filter(
  2986. PlTransportDbLink.tenant_no == tenant_no,
  2987. PlTransportDbLink.plan_id == id,
  2988. PlTransportDbLink.transport_id == transport.id,
  2989. PlTransportDbLink.del_flag == 0,
  2990. )
  2991. .all()
  2992. )
  2993. inventory_records: list[TransportInventoryLinkRead] = []
  2994. for link in links:
  2995. product = (
  2996. db.query(PlPlanProduct)
  2997. .filter(
  2998. PlPlanProduct.id == link.plan_product_id,
  2999. PlPlanProduct.del_flag == 0,
  3000. )
  3001. .first()
  3002. )
  3003. inventory = (
  3004. db.query(DbInboundOutbound)
  3005. .filter(
  3006. DbInboundOutbound.id == link.db_inventory_id,
  3007. DbInboundOutbound.tenant_no == tenant_no,
  3008. DbInboundOutbound.del_flag == 0,
  3009. )
  3010. .first()
  3011. )
  3012. inventory_records.append(
  3013. build_transport_inventory_link_read(
  3014. db,
  3015. plan_product_id=link.plan_product_id,
  3016. product_name=(product.product_name if product else "") or "",
  3017. db_purchase_plan_id=link.db_purchase_plan_id,
  3018. db_inventory_id=link.db_inventory_id,
  3019. batch_no=(inventory.batch_no if inventory else "") or "",
  3020. plan_no=plan.plan_no or "",
  3021. stop_type=link.stop_type,
  3022. stop_id=link.stop_id,
  3023. business_type=(inventory.business_type if inventory else None),
  3024. partner_company=(inventory.partner_company if inventory else None),
  3025. )
  3026. )
  3027. io_order_brief = None
  3028. active_io = get_active_io_order_for_plan(db, tenant_no, plan)
  3029. if active_io:
  3030. kind, header = active_io
  3031. from app.services.pl_io_order_service import build_io_order_site_url
  3032. token = (header.site_access_token or "").strip()
  3033. io_order_brief = TransportIoOrderBrief(
  3034. kind=kind,
  3035. order_id=header.id,
  3036. order_no=header.order_no,
  3037. site_access_token=token or None,
  3038. site_url=build_io_order_site_url(kind, token) if token else None,
  3039. status=header.status,
  3040. )
  3041. return PlanArrangeInfoRead(
  3042. driver_url=driver_url,
  3043. license_plate=transport.license_plate or plan.license_plate,
  3044. site_records=site_records,
  3045. inventory_records=inventory_records,
  3046. io_order=io_order_brief,
  3047. requires_site_link=requires_site_link,
  3048. )
  3049. # =============================================================================
  3050. # 跟进状态:等通知 / 取消等通知
  3051. # =============================================================================
  3052. @router.post("/{id}/wait-notice", response_model=PlanRead)
  3053. def mark_plan_wait_notice(
  3054. id: str,
  3055. db: Session = Depends(get_db),
  3056. user: DbUserContext = Depends(get_db_current_user),
  3057. ) -> PlanRead:
  3058. """将跟进状态从「待安排」设为「等通知」。"""
  3059. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3060. plan = _get_plan(db, tenant_no, id, user)
  3061. if plan.follow_status == "cancelled":
  3062. raise HTTPException(status_code=400, detail="已取消的计划不能标记等通知")
  3063. if plan.archive_status == "archived":
  3064. raise HTTPException(status_code=400, detail="已归档的计划不能标记等通知")
  3065. if plan.follow_status not in ("pending_arrange", "drafting"):
  3066. raise HTTPException(status_code=400, detail="仅待安排或草稿中状态可标记为等通知")
  3067. plan.follow_status = "wait_notice"
  3068. plan.update_by = user.id
  3069. db.commit()
  3070. db.refresh(plan)
  3071. return plan
  3072. @router.post("/{id}/cancel-wait-notice", response_model=PlanRead)
  3073. def cancel_plan_wait_notice(
  3074. id: str,
  3075. db: Session = Depends(get_db),
  3076. user: DbUserContext = Depends(get_db_current_user),
  3077. ) -> PlanRead:
  3078. """将跟进状态从「等通知」改回「待安排」。"""
  3079. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3080. plan = _get_plan(db, tenant_no, id, user)
  3081. if plan.follow_status != "wait_notice":
  3082. raise HTTPException(status_code=400, detail="仅等通知状态可取消等通知")
  3083. if plan.archive_status == "archived":
  3084. raise HTTPException(status_code=400, detail="已归档的计划不能取消等通知")
  3085. plan.follow_status = "pending_arrange"
  3086. plan.update_by = user.id
  3087. db.commit()
  3088. db.refresh(plan)
  3089. return plan
  3090. FORCE_FOLLOW_STATUS_PERM = "pl:plan:force-follow-status"
  3091. FORCE_VOID_PERM = "pl:plan:force-void"
  3092. ARCHIVE_PRICE_EXPORT_FIELDS = frozenset(
  3093. {
  3094. "unitPrice",
  3095. "amount",
  3096. "freightPrice",
  3097. "freightAmount",
  3098. }
  3099. )
  3100. ALLOWED_FORCE_FOLLOW_STATUSES = frozenset(
  3101. {"drafting", "wait_notice", "pending_arrange", "arranged", "completed", "cancelled"}
  3102. )
  3103. @router.post("/{id}/force-follow-status", response_model=PlanRead)
  3104. def force_plan_follow_status(
  3105. id: str,
  3106. payload: PlanForceFollowStatusBody,
  3107. db: Session = Depends(get_db),
  3108. user: DbUserContext = Depends(get_db_current_user),
  3109. ) -> PlanRead:
  3110. """强制修改跟进状态(仅改字段,不作废联动、不补运输安排)。需权限 pl:plan:force-follow-status。"""
  3111. if user.role != SUPER_ADMIN_ROLE:
  3112. codes = get_user_menu_codes(db, user.id)
  3113. if FORCE_FOLLOW_STATUS_PERM not in codes:
  3114. raise HTTPException(status_code=403, detail="无强制修改跟进状态权限")
  3115. next_status = (payload.follow_status or "").strip()
  3116. if next_status not in ALLOWED_FORCE_FOLLOW_STATUSES:
  3117. raise HTTPException(
  3118. status_code=400,
  3119. detail="跟进状态仅支持:草稿中/等通知/待安排/已安排/已完成/已取消",
  3120. )
  3121. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3122. plan = _get_plan(db, tenant_no, id, user)
  3123. plan.follow_status = next_status
  3124. plan.update_by = user.id
  3125. db.commit()
  3126. db.refresh(plan)
  3127. return plan
  3128. # =============================================================================
  3129. # 取消计划
  3130. # =============================================================================
  3131. @router.post("/{id}/cancel", response_model=PlanRead)
  3132. def cancel_plan(
  3133. id: str,
  3134. body: Optional[CancelReasonBody] = Body(default=None),
  3135. db: Session = Depends(get_db),
  3136. user: DbUserContext = Depends(get_db_current_user),
  3137. ) -> PlanRead:
  3138. """作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。"""
  3139. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3140. plan = _get_plan(db, tenant_no, id, user)
  3141. if plan.follow_status == "cancelled":
  3142. raise HTTPException(status_code=400, detail="计划已作废")
  3143. if plan.archive_status == "archived":
  3144. raise HTTPException(status_code=400, detail="已归档的计划不能作废")
  3145. lock_reason = plan_execution_lock_reason(db, tenant_no, plan.id)
  3146. if lock_reason:
  3147. raise HTTPException(
  3148. status_code=400, detail=execution_lock_message(lock_reason, "void")
  3149. )
  3150. if plan.transport_id:
  3151. transport = (
  3152. db.query(PlTransport)
  3153. .filter(
  3154. PlTransport.id == plan.transport_id,
  3155. PlTransport.tenant_no == tenant_no,
  3156. PlTransport.del_flag == 0,
  3157. )
  3158. .first()
  3159. )
  3160. if transport:
  3161. try:
  3162. revoke_regeneratable_dibang_for_transport(
  3163. db,
  3164. tenant_no=tenant_no,
  3165. transport_id=transport.id,
  3166. user_id=user.id,
  3167. )
  3168. except ValueError as exc:
  3169. raise HTTPException(status_code=400, detail=str(exc)) from exc
  3170. revoke_site_links_for_transport(
  3171. db,
  3172. tenant_no=tenant_no,
  3173. transport_id=transport.id,
  3174. user_id=user.id,
  3175. )
  3176. transport.transport_status = "cancelled"
  3177. transport.update_by = user.id
  3178. void_io_orders_for_plan(
  3179. db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id
  3180. )
  3181. now = datetime.now()
  3182. reason = normalize_optional_reason(body.cancel_reason if body else None)
  3183. plan.follow_status = "cancelled"
  3184. plan.archive_status = "cancelled"
  3185. plan.transport_status = "pending"
  3186. plan.cancel_reason = reason
  3187. plan.cancel_time = now
  3188. plan.cancel_by = user.id
  3189. plan.update_by = user.id
  3190. db.commit()
  3191. db.refresh(plan)
  3192. return plan
  3193. def _assert_can_force_void(db: Session, user: DbUserContext) -> None:
  3194. if user.role == SUPER_ADMIN_ROLE:
  3195. return
  3196. if FORCE_VOID_PERM not in get_user_menu_codes(db, user.id):
  3197. raise HTTPException(status_code=403, detail="无强制作废权限")
  3198. def _load_force_void_plan(
  3199. db: Session, user: DbUserContext, plan_id: str
  3200. ):
  3201. _assert_can_force_void(db, user)
  3202. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3203. plan = _get_plan(db, tenant_no, plan_id, user)
  3204. if plan.follow_status == "cancelled":
  3205. raise HTTPException(status_code=400, detail="计划已作废")
  3206. if plan.archive_status == "archived":
  3207. raise HTTPException(status_code=400, detail="已归档的计划不能强制作废")
  3208. lock_reason = plan_execution_lock_reason(db, tenant_no, plan.id)
  3209. if not lock_reason:
  3210. raise HTTPException(status_code=400, detail="该计划尚未开始,请使用作废")
  3211. return tenant_no, plan, lock_reason
  3212. @router.get("/{id}/force-void-preview", response_model=PlanForceVoidPreview)
  3213. def preview_force_void_plan(
  3214. id: str,
  3215. db: Session = Depends(get_db),
  3216. user: DbUserContext = Depends(get_db_current_user),
  3217. ) -> PlanForceVoidPreview:
  3218. """强制作废前的冲销清单。仅超级管理员,或已被授予 pl:plan:force-void 的用户。"""
  3219. tenant_no, _plan, lock_reason = _load_force_void_plan(db, user, id)
  3220. impact = collect_force_void_impact(db, tenant_no=tenant_no, plan_id=id)
  3221. return PlanForceVoidPreview(
  3222. execution_lock_reason=lock_reason,
  3223. weighing_slips=impact.weighing_slips,
  3224. inventories=impact.inventories,
  3225. io_orders=impact.io_orders,
  3226. notice=impact.notice(),
  3227. )
  3228. @router.post("/{id}/force-void", response_model=PlanRead)
  3229. def force_void_plan(
  3230. id: str,
  3231. body: Optional[CancelReasonBody] = Body(default=None),
  3232. db: Session = Depends(get_db),
  3233. user: DbUserContext = Depends(get_db_current_user),
  3234. ) -> PlanRead:
  3235. """强制作废已开始的运输计划:作废地磅单、退回数量、作废出入库单。"""
  3236. tenant_no, plan, _lock_reason = _load_force_void_plan(db, user, id)
  3237. reason = normalize_optional_reason(body.cancel_reason if body else None)
  3238. if not reason:
  3239. raise HTTPException(status_code=400, detail="请填写作废原因")
  3240. apply_force_void_downstream(
  3241. db,
  3242. tenant_no=tenant_no,
  3243. plan_id=plan.id,
  3244. transport_id=plan.transport_id,
  3245. user_id=user.id,
  3246. reason=reason,
  3247. )
  3248. now = datetime.now()
  3249. plan.follow_status = "cancelled"
  3250. plan.archive_status = "cancelled"
  3251. plan.transport_status = "pending"
  3252. plan.cancel_reason = reason
  3253. plan.cancel_time = now
  3254. plan.cancel_by = user.id
  3255. plan.update_by = user.id
  3256. db.commit()
  3257. db.refresh(plan)
  3258. return plan
  3259. # =============================================================================
  3260. # 归档计划
  3261. # =============================================================================
  3262. @router.post("/{id}/archive-draft", response_model=PlanWithProducts)
  3263. def archive_plan_draft(
  3264. id: str,
  3265. payload: PlanArchiveBody = Body(...),
  3266. db: Session = Depends(get_db),
  3267. user: DbUserContext = Depends(get_db_current_user),
  3268. ) -> PlanWithProducts:
  3269. """暂存归档填写内容:不改库存、不标已归档。"""
  3270. if not user_can_archive_plan(db, user):
  3271. raise HTTPException(status_code=403, detail="无运输计划归档操作权限")
  3272. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3273. plan = _get_plan(db, tenant_no, id, user)
  3274. if plan.follow_status == "cancelled":
  3275. raise HTTPException(status_code=400, detail="已作废的计划不能暂存归档")
  3276. if plan.archive_status == "archived":
  3277. raise HTTPException(status_code=400, detail="已归档的计划请使用调整保存,不能暂存")
  3278. use_stop_archive = payload.load is not None and payload.deliveries is not None
  3279. if use_stop_archive:
  3280. apply_archive_stop_quantities(
  3281. db,
  3282. tenant_no=tenant_no,
  3283. plan=plan,
  3284. load_block=payload.load,
  3285. delivery_blocks=payload.deliveries or [],
  3286. user_id=user.id,
  3287. strict=False,
  3288. )
  3289. sync_plan_products_from_stop_archive(
  3290. db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id
  3291. )
  3292. if payload.products:
  3293. apply_archive_brand_qtys_only(
  3294. db,
  3295. tenant_no=tenant_no,
  3296. plan=plan,
  3297. items=payload.products,
  3298. user_id=user.id,
  3299. )
  3300. elif payload.products:
  3301. apply_archive_product_quantities(
  3302. db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id
  3303. )
  3304. if payload.freight is not None:
  3305. apply_archive_freight(
  3306. db,
  3307. tenant_no=tenant_no,
  3308. plan=plan,
  3309. freight=payload.freight,
  3310. user_id=user.id,
  3311. strict=False,
  3312. )
  3313. if payload.feedbacks is not None and plan.transport_id:
  3314. apply_archive_stop_feedback_attachments(
  3315. db,
  3316. tenant_no=tenant_no,
  3317. plan=plan,
  3318. feedbacks=payload.feedbacks,
  3319. user_id=user.id,
  3320. dept_id=user.dept_id or "",
  3321. )
  3322. plan.archive_status = "draft"
  3323. plan.update_by = user.id
  3324. record_plan_change(
  3325. db,
  3326. tenant_no=tenant_no,
  3327. plan_id=plan.id,
  3328. change_type="archive_draft",
  3329. change_content="暂存归档内容",
  3330. user_id=user.id,
  3331. user_name=user.name or None,
  3332. dept_id=user.dept_id,
  3333. )
  3334. db.commit()
  3335. return get_plan(id, db, user)
  3336. @router.post("/{id}/archive", response_model=PlanWithProducts)
  3337. def archive_plan(
  3338. id: str,
  3339. payload: Optional[PlanArchiveBody] = Body(default=None),
  3340. db: Session = Depends(get_db),
  3341. user: DbUserContext = Depends(get_db_current_user),
  3342. ) -> PlanWithProducts:
  3343. if not user_can_archive_plan(db, user):
  3344. raise HTTPException(status_code=403, detail="无运输计划归档操作权限")
  3345. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3346. plan = _get_plan(db, tenant_no, id, user)
  3347. lock_archive_inventory(db, tenant_no=tenant_no, plan=plan, refresh_plan=True)
  3348. if plan.follow_status == "cancelled":
  3349. raise HTTPException(status_code=400, detail="已作废的计划不能归档")
  3350. already_archived = plan.archive_status == "archived"
  3351. if not already_archived:
  3352. products = (
  3353. db.query(PlPlanProduct)
  3354. .filter(
  3355. PlPlanProduct.plan_id == plan.id,
  3356. PlPlanProduct.tenant_no == tenant_no,
  3357. PlPlanProduct.del_flag == 0,
  3358. )
  3359. .order_by(PlPlanProduct.order_num)
  3360. .all()
  3361. )
  3362. validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
  3363. if payload is None:
  3364. if already_archived:
  3365. raise HTTPException(status_code=400, detail="计划已归档,请提交归档表单以调整内容")
  3366. plan.archive_status = "archived"
  3367. plan.complete_status = "yes"
  3368. plan.follow_status = "completed"
  3369. plan.update_by = user.id
  3370. db.commit()
  3371. return get_plan(id, db, user)
  3372. use_stop_archive = payload.load is not None and payload.deliveries is not None
  3373. if use_stop_archive:
  3374. if not payload.deliveries:
  3375. raise HTTPException(status_code=400, detail="请填写全部送货段归档")
  3376. from app.services.pl_contract_plan_qty import (
  3377. ARCHIVE_LINE_QTY_OVERAGE_CODE,
  3378. collect_archive_line_overages,
  3379. )
  3380. overages = collect_archive_line_overages(
  3381. db,
  3382. tenant_no=tenant_no,
  3383. plan_id=plan.id,
  3384. load_block=payload.load,
  3385. delivery_blocks=payload.deliveries,
  3386. )
  3387. if overages and not payload.confirm_overage:
  3388. raise HTTPException(
  3389. status_code=422,
  3390. detail={
  3391. "code": ARCHIVE_LINE_QTY_OVERAGE_CODE,
  3392. "message": "归档实际数量超过合同行剩余,请确认超交",
  3393. "items": overages,
  3394. },
  3395. )
  3396. apply_archive_stop_quantities(
  3397. db,
  3398. tenant_no=tenant_no,
  3399. plan=plan,
  3400. load_block=payload.load,
  3401. delivery_blocks=payload.deliveries,
  3402. user_id=user.id,
  3403. )
  3404. sync_plan_products_from_stop_archive(
  3405. db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id
  3406. )
  3407. if payload.products:
  3408. apply_archive_brand_qtys_only(
  3409. db,
  3410. tenant_no=tenant_no,
  3411. plan=plan,
  3412. items=payload.products,
  3413. user_id=user.id,
  3414. )
  3415. apply_archive_inventory_for_plan_stops(
  3416. db,
  3417. tenant_no=tenant_no,
  3418. plan=plan,
  3419. user_id=user.id,
  3420. dept_id=user.dept_id or "",
  3421. )
  3422. else:
  3423. if not payload.products:
  3424. raise HTTPException(status_code=400, detail="请提交产品归档明细")
  3425. apply_archive_product_quantities(
  3426. db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id
  3427. )
  3428. apply_archive_inventory_for_plan(
  3429. db,
  3430. tenant_no=tenant_no,
  3431. plan=plan,
  3432. user_id=user.id,
  3433. dept_id=user.dept_id or "",
  3434. )
  3435. if payload.freight is not None:
  3436. apply_archive_freight(
  3437. db, tenant_no=tenant_no, plan=plan, freight=payload.freight, user_id=user.id
  3438. )
  3439. elif int(plan.has_freight or 0) == 1 and not already_archived:
  3440. if not plan.transport_id:
  3441. raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
  3442. if payload.feedbacks is not None and plan.transport_id:
  3443. apply_archive_stop_feedback_attachments(
  3444. db,
  3445. tenant_no=tenant_no,
  3446. plan=plan,
  3447. feedbacks=payload.feedbacks,
  3448. user_id=user.id,
  3449. dept_id=user.dept_id or "",
  3450. )
  3451. if not already_archived:
  3452. plan.archive_status = "archived"
  3453. plan.complete_status = "yes"
  3454. plan.follow_status = "completed"
  3455. plan.update_by = user.id
  3456. record_plan_change(
  3457. db,
  3458. tenant_no=tenant_no,
  3459. plan_id=plan.id,
  3460. change_type="archive_adjust" if already_archived else "archive",
  3461. change_content="调整归档内容" if already_archived else "运输计划归档",
  3462. user_id=user.id,
  3463. user_name=user.name or None,
  3464. dept_id=user.dept_id,
  3465. )
  3466. db.commit()
  3467. return get_plan(id, db, user)
  3468. @router.patch("/{id}/driver-feedback", response_model=PlanWithProducts)
  3469. def update_plan_driver_feedback(
  3470. id: str,
  3471. payload: PlanDriverFeedbackUpdateBody,
  3472. db: Session = Depends(get_db),
  3473. user: DbUserContext = Depends(get_db_current_user),
  3474. ) -> PlanWithProducts:
  3475. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3476. plan = _get_plan(db, tenant_no, id, user)
  3477. apply_plan_driver_feedback(
  3478. db,
  3479. tenant_no=tenant_no,
  3480. plan=plan,
  3481. payload=payload,
  3482. user_id=user.id,
  3483. )
  3484. record_plan_change(
  3485. db,
  3486. tenant_no=tenant_no,
  3487. plan_id=plan.id,
  3488. change_type="feedback_adjust",
  3489. change_content="归档人员更新司机回传信息",
  3490. user_id=user.id,
  3491. user_name=user.name or None,
  3492. dept_id=user.dept_id,
  3493. )
  3494. plan.update_by = user.id
  3495. db.commit()
  3496. return get_plan(id, db, user)
  3497. @router.patch("/{id}/site-feedback/{link_id}", response_model=PlanWithProducts)
  3498. def update_plan_site_feedback(
  3499. id: str,
  3500. link_id: str,
  3501. payload: PlanSiteFeedbackUpdateBody,
  3502. db: Session = Depends(get_db),
  3503. user: DbUserContext = Depends(get_db_current_user),
  3504. ) -> PlanWithProducts:
  3505. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3506. plan = _get_plan(db, tenant_no, id, user)
  3507. link = apply_plan_site_feedback(
  3508. db,
  3509. tenant_no=tenant_no,
  3510. plan=plan,
  3511. link_id=link_id,
  3512. payload=payload,
  3513. user_id=user.id,
  3514. )
  3515. product_label = (link.product or "现场").strip()
  3516. record_plan_change(
  3517. db,
  3518. tenant_no=tenant_no,
  3519. plan_id=plan.id,
  3520. change_type="feedback_adjust",
  3521. change_content=f"归档人员更新现场回传信息({product_label})",
  3522. user_id=user.id,
  3523. user_name=user.name or None,
  3524. dept_id=user.dept_id,
  3525. )
  3526. plan.update_by = user.id
  3527. db.commit()
  3528. return get_plan(id, db, user)
  3529. # =============================================================================
  3530. # 入库单打印模板
  3531. # =============================================================================
  3532. @router.get("/{id}/inbound-slip-template")
  3533. def get_inbound_slip_template(
  3534. id: str,
  3535. db: Session = Depends(get_db),
  3536. user: DbUserContext = Depends(get_db_current_user),
  3537. ) -> str:
  3538. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3539. plan = _get_plan(db, tenant_no, id, user)
  3540. return plan.slip_template_content or ""
  3541. @router.put("/{id}/inbound-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  3542. def save_inbound_slip_template(
  3543. id: str,
  3544. payload: PlanInboundSlipTemplateSave,
  3545. db: Session = Depends(get_db),
  3546. user: DbUserContext = Depends(get_db_current_user),
  3547. ) -> None:
  3548. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3549. plan = _get_plan(db, tenant_no, id, user)
  3550. if plan.plan_type != "inbound":
  3551. raise HTTPException(status_code=400, detail="仅入库单计划支持保存入库单打印模板")
  3552. if plan.transport_status != "arranged":
  3553. raise HTTPException(status_code=400, detail="请先完成运输安排后再保存入库单打印模板")
  3554. plan.slip_template_content = payload.slip_template_content or ""
  3555. plan.update_by = user.id
  3556. db.commit()
  3557. # =============================================================================
  3558. # 送(提)货单打印模板
  3559. # =============================================================================
  3560. @router.get("/{id}/print-delivery-slip", response_model=DeliverySlipPrintRead)
  3561. def get_print_delivery_slip(
  3562. id: str,
  3563. customerId: Optional[str] = Query(default=None, description="送货客户ID(多客户时必填)"),
  3564. db: Session = Depends(get_db),
  3565. user: DbUserContext = Depends(get_db_current_user),
  3566. ):
  3567. """按送货客户聚合送(提)货单打印数据;不传 customerId 时仅返回客户选项。"""
  3568. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3569. plan = _get_plan(db, tenant_no, id, user)
  3570. plan_date = plan.plan_date.isoformat() if plan.plan_date else None
  3571. try:
  3572. return get_delivery_slip_print_context(
  3573. db,
  3574. tenant_no=tenant_no,
  3575. plan_id=plan.id,
  3576. plan_no=plan.plan_no or "",
  3577. plan_date=plan_date,
  3578. customer_id=customerId,
  3579. )
  3580. except ValueError as exc:
  3581. raise HTTPException(status_code=400, detail=str(exc)) from exc
  3582. @router.get("/{id}/delivery-slip-template")
  3583. def get_delivery_slip_template(
  3584. id: str,
  3585. db: Session = Depends(get_db),
  3586. user: DbUserContext = Depends(get_db_current_user),
  3587. ) -> str:
  3588. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3589. plan = _get_plan(db, tenant_no, id, user)
  3590. return plan.delivery_slip_template_content or ""
  3591. @router.put("/{id}/delivery-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  3592. def save_delivery_slip_template(
  3593. id: str,
  3594. payload: PlanDeliverySlipTemplateSave,
  3595. db: Session = Depends(get_db),
  3596. user: DbUserContext = Depends(get_db_current_user),
  3597. ) -> None:
  3598. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3599. plan = _get_plan(db, tenant_no, id, user)
  3600. if plan.plan_type == "inbound":
  3601. raise HTTPException(status_code=400, detail="入库单计划请使用入库单打印模板")
  3602. if plan.transport_status != "arranged":
  3603. raise HTTPException(status_code=400, detail="请先完成运输安排后再保存送(提)货单打印模板")
  3604. plan.delivery_slip_template_content = payload.delivery_slip_template_content or ""
  3605. plan.update_by = user.id
  3606. db.commit()
  3607. # =============================================================================
  3608. # 分装单打印模板
  3609. # =============================================================================
  3610. @router.get("/{id}/subpack-slip-template")
  3611. def get_subpack_slip_template(
  3612. id: str,
  3613. db: Session = Depends(get_db),
  3614. user: DbUserContext = Depends(get_db_current_user),
  3615. ) -> str:
  3616. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3617. plan = _get_plan(db, tenant_no, id, user)
  3618. return plan.subpack_slip_template_content or ""
  3619. @router.put("/{id}/subpack-slip-template", status_code=status.HTTP_204_NO_CONTENT)
  3620. def save_subpack_slip_template(
  3621. id: str,
  3622. payload: PlanSubpackSlipTemplateSave,
  3623. db: Session = Depends(get_db),
  3624. user: DbUserContext = Depends(get_db_current_user),
  3625. ) -> None:
  3626. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3627. plan = _get_plan(db, tenant_no, id, user)
  3628. if plan.plan_type == "inbound":
  3629. raise HTTPException(status_code=400, detail="入库单计划请使用入库单打印模板")
  3630. plan.subpack_slip_template_content = payload.subpack_slip_template_content or ""
  3631. plan.update_by = user.id
  3632. db.commit()
  3633. @router.post("/{id}/subpack-slip-no")
  3634. def ensure_subpack_slip_no(
  3635. id: str,
  3636. db: Session = Depends(get_db),
  3637. user: DbUserContext = Depends(get_db_current_user),
  3638. ) -> str:
  3639. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3640. plan = _get_plan(db, tenant_no, id, user)
  3641. if plan.plan_type == "inbound":
  3642. raise HTTPException(status_code=400, detail="入库单计划不支持分装单号")
  3643. slip_no = _ensure_subpack_slip_no(db, tenant_no, plan)
  3644. plan.update_by = user.id
  3645. db.commit()
  3646. return slip_no
  3647. # =============================================================================
  3648. # 删除计划
  3649. # =============================================================================
  3650. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  3651. def delete_plan(
  3652. id: str,
  3653. db: Session = Depends(get_db),
  3654. user: DbUserContext = Depends(get_db_current_user),
  3655. ) -> None:
  3656. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  3657. plan = _get_plan(db, tenant_no, id, user)
  3658. if plan.follow_status not in ("pending_arrange", "wait_notice", "drafting"):
  3659. raise HTTPException(status_code=400, detail="只能删除待安排、草稿中或等通知且未安排运输的计划")
  3660. if plan.transport_status != "pending":
  3661. raise HTTPException(status_code=400, detail="已安排运输的计划不能删除")
  3662. if plan.archive_status == "archived":
  3663. raise HTTPException(status_code=400, detail="已归档的计划不能删除")
  3664. assert_row_deletable_by_creator(plan, user_info_from_context(user))
  3665. plan.del_flag = 1
  3666. plan.update_by = user.id
  3667. db.query(PlPlanProduct).filter(
  3668. PlPlanProduct.plan_id == id,
  3669. PlPlanProduct.tenant_no == tenant_no,
  3670. PlPlanProduct.del_flag == 0,
  3671. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  3672. db.query(PlPlanTransport).filter(
  3673. PlPlanTransport.plan_id == id,
  3674. PlPlanTransport.tenant_no == tenant_no,
  3675. PlPlanTransport.del_flag == 0,
  3676. ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
  3677. db.commit()
  3678. # =============================================================================
  3679. # 辅助函数
  3680. # =============================================================================
  3681. def _get_plan(db: Session, tenant_no: str, plan_id: str, user: DbUserContext) -> PlPlan:
  3682. plan = (
  3683. db.query(PlPlan)
  3684. .filter(
  3685. PlPlan.id == plan_id,
  3686. PlPlan.tenant_no == tenant_no,
  3687. PlPlan.del_flag == 0,
  3688. )
  3689. .first()
  3690. )
  3691. if not plan:
  3692. raise HTTPException(status_code=404, detail="计划不存在")
  3693. assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
  3694. return plan