admin.py 323 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721
  1. """运营端接口"""
  2. import json
  3. import logging
  4. import random
  5. import string
  6. import uuid
  7. from typing import Optional, List, Any, Tuple
  8. from datetime import datetime
  9. from decimal import Decimal
  10. from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, Form, Body
  11. from sqlalchemy.orm import Session
  12. from sqlalchemy import and_, or_, func
  13. from sqlalchemy.exc import IntegrityError
  14. from app.core.database import get_db
  15. from app.core.auth import get_current_user
  16. from app.core.security import get_password_hash, verify_password
  17. from app.core.response import success_response, error_response
  18. from app.core.storage import storage
  19. from app.models.models import (
  20. SysUser, SysRegion, BasProductCategory, BasProductUnit, BasProduct,
  21. SysUserRegionScope, SysUserRegionScopeHis,
  22. SysMenu, SysUserMenuScope, SysUserMenuScopeHis,
  23. BizOrder, BizOrderItem, BizPriceList, BizPriceListItem,
  24. OrdClientSettlement, OrdClientSettlementItem,
  25. OrdSupplierSettlement, OrdSupplierSettlementItem,
  26. )
  27. from app.api.upload import validate_file, ALLOWED_IMAGE_TYPES, MAX_IMAGE_SIZE
  28. from app.crud.region import region_crud
  29. from app.crud.category import category_crud
  30. from app.crud.unit import unit_crud
  31. from app.crud.user import user_crud
  32. from app.crud.password_history import password_history_crud
  33. from app.crud.product import product_crud
  34. from app.crud.address import address_crud
  35. from app.core.utils import generate_id
  36. from app.core.request_payload import resolve_request_payload
  37. from app.services.china_region_tree import get_china_region_tree_cached
  38. from app.utils.product_spec import normalize_product_spec
  39. from app.utils.product_suggested_price import normalize_suggested_supplier_price
  40. from app.utils.batch_lookup import (
  41. prefetch_products_categories_units as _prefetch_products_categories_units,
  42. build_category_path_map as _build_category_path_map,
  43. )
  44. router = APIRouter(prefix="/web/admin", tags=["运营端管理接口"])
  45. logger = logging.getLogger(__name__)
  46. def _numeric_to_float_optional(v) -> Optional[float]:
  47. if v is None:
  48. return None
  49. return float(v)
  50. def _category_path_display(db: Session, category_id: Optional[str]) -> str:
  51. """一级/二级分类用 / 连接(自底向上回溯父级)。"""
  52. if not category_id:
  53. return ""
  54. return _build_category_path_map(db, [str(category_id)]).get(str(category_id).strip(), "")
  55. def _supplier_pickup_address_block(db: Session, supplier_id: Optional[str]) -> dict:
  56. """供应商默认取货地址;无默认时取第一条 pickup 类型地址。"""
  57. empty = {"fullAddress": None, "contactPerson": None, "contactPhone": None}
  58. if not supplier_id:
  59. return empty
  60. addr = address_crud.get_default(db, supplier_id, "pickup")
  61. if not addr:
  62. rows = address_crud.get_by_user_and_type(db, supplier_id, "pickup")
  63. addr = rows[0] if rows else None
  64. if not addr:
  65. return empty
  66. segs = [
  67. getattr(addr, "province", None),
  68. getattr(addr, "city", None),
  69. getattr(addr, "district", None),
  70. getattr(addr, "detail_address", None),
  71. ]
  72. full = "".join(s for s in segs if s)
  73. return {
  74. "fullAddress": full or None,
  75. "contactPerson": addr.contact_person,
  76. "contactPhone": addr.contact_phone,
  77. }
  78. def require_admin(current_user: dict = Depends(get_current_user)):
  79. """验证运营人员权限(admin / super_admin)"""
  80. if current_user.get("role") not in {"admin", "super_admin"}:
  81. raise HTTPException(status_code=403, detail="需要运营人员权限")
  82. return current_user
  83. def get_admin_region_filter(db: Session, current_user: dict) -> Optional[str]:
  84. """兼容旧逻辑:返回单区域过滤ID。多区域场景返回排序后首个区域。"""
  85. allowed = get_admin_allowed_region_ids(db, current_user)
  86. if allowed is None:
  87. return None
  88. if not allowed:
  89. return None
  90. return sorted(allowed)[0]
  91. def get_admin_allowed_region_ids(db: Session, current_user: dict) -> Optional[set[str]]:
  92. """获取管理员允许访问的区域集合。
  93. - super_admin: 返回 None(表示不限制)
  94. - admin: 返回可访问区域集合(多区域)
  95. """
  96. user_id = current_user.get("user_id")
  97. if not user_id:
  98. return set()
  99. if current_user.get("role") == "super_admin":
  100. return None
  101. user = user_crud.get(db, user_id)
  102. if not user:
  103. return set()
  104. scopes = (
  105. db.query(SysUserRegionScope.region_id)
  106. .filter(
  107. SysUserRegionScope.user_id == user_id,
  108. SysUserRegionScope.is_del == 0,
  109. )
  110. .all()
  111. )
  112. scope_ids = {str(row[0]).strip() for row in scopes if row and row[0]}
  113. if scope_ids:
  114. return scope_ids
  115. # 向后兼容:若尚未配置多区域表,沿用 sys_user.region_id
  116. if user.region_id:
  117. return {str(user.region_id)}
  118. return set()
  119. def _effective_settlement_region_id(
  120. admin_region_id: Optional[str],
  121. tab_or_filter_region_id: Optional[str],
  122. ) -> Optional[str]:
  123. """结算相关接口:区域管理员固定为其绑定区域;超级管理员可按 Tab 的 regionId 筛选。"""
  124. if admin_region_id:
  125. return admin_region_id
  126. return tab_or_filter_region_id or None
  127. def _aggregate_client_settlement_history(
  128. db: Session,
  129. eff_region_id: Optional[str],
  130. client_id: Optional[str],
  131. ) -> Tuple[float, int]:
  132. """已生成客户结算单:结算金额合计、订单笔数合计(与结算单主表 order_count 汇总一致)。"""
  133. q = db.query(
  134. func.coalesce(func.sum(OrdClientSettlement.total_amount), 0),
  135. func.coalesce(func.sum(OrdClientSettlement.order_count), 0),
  136. ).filter(OrdClientSettlement.is_del == 0)
  137. if client_id:
  138. q = q.filter(OrdClientSettlement.client_id == client_id)
  139. if eff_region_id:
  140. sub = (
  141. db.query(OrdClientSettlementItem.settlement_id)
  142. .join(BizOrder, BizOrder.id == OrdClientSettlementItem.order_id)
  143. .filter(
  144. OrdClientSettlementItem.is_del == 0,
  145. BizOrder.is_del == 0,
  146. BizOrder.region_id == eff_region_id,
  147. )
  148. .distinct()
  149. )
  150. q = q.filter(OrdClientSettlement.id.in_(sub))
  151. row = q.one()
  152. return float(row[0] or 0), int(row[1] or 0)
  153. def _aggregate_supplier_settlement_history(
  154. db: Session,
  155. eff_region_id: Optional[str],
  156. supplier_id: Optional[str],
  157. ) -> Tuple[float, int]:
  158. """已生成供应商结算单:结算金额合计、订单笔数合计。"""
  159. q = db.query(
  160. func.coalesce(func.sum(OrdSupplierSettlement.total_amount), 0),
  161. func.coalesce(func.sum(OrdSupplierSettlement.order_count), 0),
  162. ).filter(OrdSupplierSettlement.is_del == 0)
  163. if supplier_id:
  164. q = q.filter(OrdSupplierSettlement.supplier_id == supplier_id)
  165. if eff_region_id:
  166. sub = (
  167. db.query(OrdSupplierSettlementItem.settlement_id)
  168. .join(BizOrder, BizOrder.id == OrdSupplierSettlementItem.order_id)
  169. .filter(
  170. OrdSupplierSettlementItem.is_del == 0,
  171. BizOrder.is_del == 0,
  172. BizOrder.region_id == eff_region_id,
  173. )
  174. .distinct()
  175. )
  176. q = q.filter(OrdSupplierSettlement.id.in_(sub))
  177. row = q.one()
  178. return float(row[0] or 0), int(row[1] or 0)
  179. _REGION_MINUTES_UNSET = object()
  180. def _minutes_value_from_request(request: dict, camel: str, snake: str):
  181. """解析区域配送相关分钟配置;键不存在返回 _REGION_MINUTES_UNSET,空值为 None。"""
  182. if camel not in request and snake not in request:
  183. return _REGION_MINUTES_UNSET
  184. raw = request.get(camel, request.get(snake))
  185. if raw is None or raw == "":
  186. return None
  187. try:
  188. n = int(raw)
  189. except (TypeError, ValueError):
  190. raise ValueError("预计配送时长、预警提前时间须为非负整数(分钟)")
  191. if n < 0:
  192. raise ValueError("预计配送时长、预警提前时间须为非负整数(分钟)")
  193. return n
  194. def _apply_region_minutes_to_data(request: dict, data: dict):
  195. """将 expectedDeliveryMinutes / deliveryWarnAdvanceMinutes 写入 data(字典为 create_data 或 update_data)。失败返回 error_response 的 dict。"""
  196. try:
  197. ed = _minutes_value_from_request(
  198. request, "expectedDeliveryMinutes", "expected_delivery_minutes"
  199. )
  200. wa = _minutes_value_from_request(
  201. request, "deliveryWarnAdvanceMinutes", "delivery_warn_advance_minutes"
  202. )
  203. except ValueError as e:
  204. return error_response(400, str(e))
  205. if ed is not _REGION_MINUTES_UNSET:
  206. data["expected_delivery_minutes"] = ed
  207. if wa is not _REGION_MINUTES_UNSET:
  208. data["delivery_warn_advance_minutes"] = wa
  209. return None
  210. def _resolve_admin_payload_with_crypto_fallback(request: dict, current_user: dict):
  211. """兼容明文/密文请求,返回业务 payload。失败时返回 error_response。"""
  212. payload = resolve_request_payload(request, current_user, source="admin")
  213. if not payload:
  214. req_dict = request if isinstance(request, dict) else {}
  215. if req_dict.get("enc") == 1:
  216. return {}, error_response(400, "密文解密失败")
  217. return payload if isinstance(payload, dict) else {}, None
  218. # ============ 中国行政区划(省市区,bas_china_region)============
  219. @router.get("/china-region-tree", response_model=dict)
  220. def get_china_region_tree_admin(
  221. db: Session = Depends(get_db),
  222. current_user: dict = Depends(require_admin),
  223. ):
  224. """省市区三级树,供运营端级联选择器等使用(数据源 bas_china_region)。"""
  225. return success_response({"tree": get_china_region_tree_cached(db)})
  226. # ============ 区域管理 ============
  227. @router.get("/regions", response_model=dict)
  228. def list_regions(
  229. page: int = Query(1, ge=1, description="页码"),
  230. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  231. keyword: Optional[str] = Query(None, description="关键词搜索"),
  232. isActive: Optional[bool] = Query(None, description="是否启用"),
  233. db: Session = Depends(get_db),
  234. current_user: dict = Depends(require_admin)
  235. ):
  236. """获取区域列表"""
  237. query = db.query(SysRegion).filter(SysRegion.is_del == 0)
  238. if keyword:
  239. query = query.filter(
  240. (SysRegion.name.like(f"%{keyword}%")) |
  241. (SysRegion.code.like(f"%{keyword}%"))
  242. )
  243. if isActive is not None:
  244. query = query.filter(SysRegion.is_active == isActive)
  245. total = query.count()
  246. skip = (page - 1) * pageSize
  247. regions = query.order_by(SysRegion.sort_order.asc()).offset(skip).limit(pageSize).all()
  248. return success_response({
  249. "list": [{"id": r.id, "name": r.name, "code": r.code, "description": r.description,
  250. "sortOrder": r.sort_order,
  251. "expectedDeliveryMinutes": r.expected_delivery_minutes,
  252. "deliveryWarnAdvanceMinutes": r.delivery_warn_advance_minutes,
  253. "isActive": r.is_active,
  254. "createAt": str(r.create_at) if r.create_at else None} for r in regions],
  255. "total": total,
  256. "page": page,
  257. "pageSize": pageSize,
  258. "totalPages": (total + pageSize - 1) // pageSize
  259. })
  260. @router.get("/regions/active", response_model=dict)
  261. def list_active_regions(
  262. db: Session = Depends(get_db),
  263. current_user: dict = Depends(require_admin)
  264. ):
  265. """获取所有启用区域(用于下拉选择)"""
  266. regions = region_crud.get_active_all(db)
  267. return success_response([{"id": r.id, "name": r.name, "code": r.code} for r in regions])
  268. @router.get("/regions/{region_id}", response_model=dict)
  269. def get_region(
  270. region_id: str,
  271. db: Session = Depends(get_db),
  272. current_user: dict = Depends(require_admin)
  273. ):
  274. """获取区域详情"""
  275. region = region_crud.get(db, region_id)
  276. if not region:
  277. return error_response(404, "区域不存在")
  278. return success_response({
  279. "id": region.id,
  280. "name": region.name,
  281. "code": region.code,
  282. "description": region.description,
  283. "sortOrder": region.sort_order,
  284. "expectedDeliveryMinutes": region.expected_delivery_minutes,
  285. "deliveryWarnAdvanceMinutes": region.delivery_warn_advance_minutes,
  286. "isActive": region.is_active,
  287. "createAt": str(region.create_at) if region.create_at else None,
  288. "createBy": region.create_by,
  289. "createName": region.create_name
  290. })
  291. @router.post("/regions", response_model=dict)
  292. def create_region(
  293. request: dict,
  294. db: Session = Depends(get_db),
  295. current_user: dict = Depends(require_admin)
  296. ):
  297. """创建区域"""
  298. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  299. if req_err is not None:
  300. return req_err
  301. # 检查编码唯一性
  302. existing = region_crud.get_by_code(db, request.get("code"))
  303. if existing:
  304. return error_response(400, "区域编码已存在")
  305. # 字段名映射
  306. create_data = {
  307. "name": request.get("name"),
  308. "code": request.get("code"),
  309. "description": request.get("description"),
  310. }
  311. if "sortOrder" in request or "sort_order" in request:
  312. create_data["sort_order"] = request.get("sortOrder", request.get("sort_order", 0))
  313. if "isActive" in request or "is_active" in request:
  314. create_data["is_active"] = request.get("isActive", request.get("is_active", True))
  315. err = _apply_region_minutes_to_data(request, create_data)
  316. if err is not None:
  317. return err
  318. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  319. region = region_crud.create(db, create_data, creator)
  320. return success_response({
  321. "id": region.id,
  322. "name": region.name,
  323. "code": region.code,
  324. "description": region.description,
  325. "sortOrder": region.sort_order,
  326. "expectedDeliveryMinutes": region.expected_delivery_minutes,
  327. "deliveryWarnAdvanceMinutes": region.delivery_warn_advance_minutes,
  328. "isActive": region.is_active,
  329. "createAt": str(region.create_at) if region.create_at else None,
  330. "createBy": region.create_by,
  331. "createName": region.create_name
  332. })
  333. @router.put("/regions/{region_id}", response_model=dict)
  334. def update_region(
  335. region_id: str,
  336. request: dict,
  337. db: Session = Depends(get_db),
  338. current_user: dict = Depends(require_admin)
  339. ):
  340. """更新区域"""
  341. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  342. if req_err is not None:
  343. return req_err
  344. # 检查编码唯一性(排除自己)
  345. if "code" in request and request["code"]:
  346. existing = region_crud.get_by_code(db, request["code"])
  347. if existing and existing.id != region_id:
  348. return error_response(400, "区域编码已存在")
  349. # 字段名映射:仅更新请求中显式传入的字段,避免未传字段被写成 None
  350. update_data: dict = {}
  351. if "name" in request:
  352. update_data["name"] = request.get("name")
  353. if "code" in request:
  354. update_data["code"] = request.get("code")
  355. if "description" in request:
  356. update_data["description"] = request.get("description")
  357. if "sortOrder" in request or "sort_order" in request:
  358. update_data["sort_order"] = request.get("sortOrder", request.get("sort_order", 0))
  359. if "isActive" in request or "is_active" in request:
  360. update_data["is_active"] = request.get("isActive", request.get("is_active", True))
  361. err = _apply_region_minutes_to_data(request, update_data)
  362. if err is not None:
  363. return err
  364. if not update_data:
  365. return error_response(400, "未检测到可更新字段")
  366. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  367. region = region_crud.update_with_history(db, region_id, update_data, updater)
  368. if not region:
  369. return error_response(404, "区域不存在")
  370. return success_response({
  371. "id": region.id,
  372. "name": region.name,
  373. "code": region.code,
  374. "description": region.description,
  375. "sortOrder": region.sort_order,
  376. "expectedDeliveryMinutes": region.expected_delivery_minutes,
  377. "deliveryWarnAdvanceMinutes": region.delivery_warn_advance_minutes,
  378. "isActive": region.is_active
  379. })
  380. @router.delete("/regions/{region_id}", response_model=dict)
  381. def delete_region(
  382. region_id: str,
  383. db: Session = Depends(get_db),
  384. current_user: dict = Depends(require_admin)
  385. ):
  386. """删除区域"""
  387. success = region_crud.soft_delete_with_history(db, region_id)
  388. if not success:
  389. return error_response(404, "区域不存在")
  390. return success_response({"id": region_id})
  391. # ============ 商品分类管理 ============
  392. def _category_price_coefficient_value(category: BasProductCategory) -> float:
  393. v = getattr(category, "price_coefficient", None)
  394. if v is None:
  395. return 0.0
  396. return float(v)
  397. @router.get("/categories", response_model=dict)
  398. def list_categories(
  399. page: int = Query(1, ge=1, description="页码"),
  400. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  401. keyword: Optional[str] = Query(None, description="关键词搜索"),
  402. isActive: Optional[bool] = Query(None, description="是否启用"),
  403. db: Session = Depends(get_db),
  404. current_user: dict = Depends(require_admin)
  405. ):
  406. """获取商品分类列表"""
  407. query = db.query(BasProductCategory).filter(BasProductCategory.is_del == 0)
  408. if keyword:
  409. query = query.filter(BasProductCategory.name.like(f"%{keyword}%"))
  410. if isActive is not None:
  411. query = query.filter(BasProductCategory.is_active == isActive)
  412. total = query.count()
  413. skip = (page - 1) * pageSize
  414. categories = query.order_by(BasProductCategory.sort_order.asc()).offset(skip).limit(pageSize).all()
  415. # 查询所有分类用于获取父分类名称
  416. all_categories = db.query(BasProductCategory).filter(BasProductCategory.is_del == 0).all()
  417. parent_map = {c.id: c.name for c in all_categories}
  418. return success_response({
  419. "list": [{
  420. "id": c.id,
  421. "name": c.name,
  422. "parentId": c.parent_id,
  423. "parentName": parent_map.get(c.parent_id) if c.parent_id else None,
  424. "sortOrder": c.sort_order,
  425. "priceCoefficient": _category_price_coefficient_value(c),
  426. "isActive": c.is_active,
  427. "imageUrl": getattr(c, "image_url", None),
  428. } for c in categories],
  429. "total": total,
  430. "page": page,
  431. "pageSize": pageSize,
  432. "totalPages": (total + pageSize - 1) // pageSize
  433. })
  434. @router.get("/categories/tree", response_model=dict)
  435. def get_category_tree(
  436. db: Session = Depends(get_db),
  437. current_user: dict = Depends(require_admin)
  438. ):
  439. """获取商品分类树形结构"""
  440. tree = category_crud.get_tree(db)
  441. return success_response(tree)
  442. @router.get("/categories/active", response_model=dict)
  443. def list_active_categories(
  444. db: Session = Depends(get_db),
  445. current_user: dict = Depends(require_admin)
  446. ):
  447. """获取所有启用分类"""
  448. categories = category_crud.get_active_all(db)
  449. return success_response([{
  450. "id": c.id,
  451. "name": c.name,
  452. "parentId": c.parent_id,
  453. "priceCoefficient": _category_price_coefficient_value(c),
  454. } for c in categories])
  455. @router.get("/categories/{category_id}", response_model=dict)
  456. def get_category(
  457. category_id: str,
  458. db: Session = Depends(get_db),
  459. current_user: dict = Depends(require_admin)
  460. ):
  461. """获取分类详情"""
  462. category = category_crud.get(db, category_id)
  463. if not category:
  464. return error_response(404, "分类不存在")
  465. return success_response({
  466. "id": category.id,
  467. "name": category.name,
  468. "parentId": category.parent_id,
  469. "sortOrder": category.sort_order,
  470. "priceCoefficient": _category_price_coefficient_value(category),
  471. "isActive": category.is_active,
  472. "imageUrl": getattr(category, "image_url", None),
  473. })
  474. @router.post("/categories", response_model=dict)
  475. def create_category(
  476. request: dict,
  477. db: Session = Depends(get_db),
  478. current_user: dict = Depends(require_admin)
  479. ):
  480. """创建商品分类"""
  481. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  482. if req_err is not None:
  483. return req_err
  484. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  485. create_data = {
  486. "name": request.get("name"),
  487. }
  488. # 前端传参使用驼峰命名:parentId/sortOrder/isActive/imageUrl
  489. if "parentId" in request or "parent_id" in request:
  490. v = request.get("parentId", request.get("parent_id"))
  491. create_data["parent_id"] = v or None
  492. if "sortOrder" in request or "sort_order" in request:
  493. create_data["sort_order"] = request.get("sortOrder", request.get("sort_order"))
  494. if "isActive" in request or "is_active" in request:
  495. create_data["is_active"] = request.get("isActive", request.get("is_active"))
  496. if "imageUrl" in request or "image_url" in request:
  497. v = request.get("imageUrl", request.get("image_url"))
  498. create_data["image_url"] = v or None
  499. if "priceCoefficient" in request or "price_coefficient" in request:
  500. raw = request.get("priceCoefficient", request.get("price_coefficient"))
  501. if raw is not None and raw != "":
  502. try:
  503. create_data["price_coefficient"] = float(raw)
  504. except (TypeError, ValueError):
  505. return error_response(400, "价格系数格式不正确")
  506. category = category_crud.create(db, create_data, creator)
  507. return success_response({
  508. "id": category.id,
  509. "name": category.name,
  510. "parentId": category.parent_id,
  511. "sortOrder": category.sort_order,
  512. "priceCoefficient": _category_price_coefficient_value(category),
  513. "isActive": category.is_active,
  514. "imageUrl": getattr(category, "image_url", None),
  515. })
  516. @router.put("/categories/{category_id}", response_model=dict)
  517. def update_category(
  518. category_id: str,
  519. request: dict,
  520. db: Session = Depends(get_db),
  521. current_user: dict = Depends(require_admin)
  522. ):
  523. """更新商品分类"""
  524. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  525. if req_err is not None:
  526. return req_err
  527. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  528. update_data: dict = {}
  529. if "name" in request:
  530. update_data["name"] = request.get("name")
  531. if "parentId" in request or "parent_id" in request:
  532. v = request.get("parentId", request.get("parent_id"))
  533. update_data["parent_id"] = v or None
  534. if "sortOrder" in request or "sort_order" in request:
  535. update_data["sort_order"] = request.get("sortOrder", request.get("sort_order"))
  536. if "isActive" in request or "is_active" in request:
  537. update_data["is_active"] = request.get("isActive", request.get("is_active"))
  538. if "imageUrl" in request or "image_url" in request:
  539. v = request.get("imageUrl", request.get("image_url"))
  540. update_data["image_url"] = v or None
  541. if "priceCoefficient" in request or "price_coefficient" in request:
  542. raw = request.get("priceCoefficient", request.get("price_coefficient"))
  543. if raw is None or raw == "":
  544. update_data["price_coefficient"] = 0.0
  545. else:
  546. try:
  547. update_data["price_coefficient"] = float(raw)
  548. except (TypeError, ValueError):
  549. return error_response(400, "价格系数格式不正确")
  550. category = category_crud.update_with_history(db, category_id, update_data, updater)
  551. if not category:
  552. return error_response(404, "分类不存在")
  553. return success_response({
  554. "id": category.id,
  555. "name": category.name,
  556. "parentId": category.parent_id,
  557. "sortOrder": category.sort_order,
  558. "priceCoefficient": _category_price_coefficient_value(category),
  559. "isActive": category.is_active,
  560. "imageUrl": getattr(category, "image_url", None),
  561. })
  562. @router.delete("/categories/{category_id}", response_model=dict)
  563. def delete_category(
  564. category_id: str,
  565. db: Session = Depends(get_db),
  566. current_user: dict = Depends(require_admin)
  567. ):
  568. """删除商品分类"""
  569. success = category_crud.soft_delete_with_history(db, category_id)
  570. if not success:
  571. return error_response(404, "分类不存在")
  572. return success_response({"id": category_id})
  573. @router.post("/categories/upload-image", response_model=dict)
  574. async def upload_category_image(
  575. file: UploadFile = File(...),
  576. folder: str = Form("categories"),
  577. current_user: dict = Depends(require_admin),
  578. ):
  579. """上传商品分类图片(MinIO)并返回图片URL"""
  580. try:
  581. content = await validate_file(file, MAX_IMAGE_SIZE, ALLOWED_IMAGE_TYPES)
  582. original_filename = file.filename or "category.png"
  583. url = storage.upload_file(content, original_filename, folder)
  584. return success_response({"url": url})
  585. except HTTPException:
  586. raise
  587. except Exception as e:
  588. return error_response(500, f"上传失败: {str(e)}")
  589. # ============ 商品单位管理 ============
  590. @router.get("/units", response_model=dict)
  591. def list_units(
  592. page: int = Query(1, ge=1, description="页码"),
  593. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  594. keyword: Optional[str] = Query(None, description="关键词搜索"),
  595. isActive: Optional[bool] = Query(None, description="是否启用"),
  596. db: Session = Depends(get_db),
  597. current_user: dict = Depends(require_admin)
  598. ):
  599. """获取商品单位列表"""
  600. query = db.query(BasProductUnit).filter(BasProductUnit.is_del == 0)
  601. if keyword:
  602. query = query.filter(
  603. (BasProductUnit.name.like(f"%{keyword}%")) |
  604. (BasProductUnit.code.like(f"%{keyword}%"))
  605. )
  606. if isActive is not None:
  607. query = query.filter(BasProductUnit.is_active == isActive)
  608. total = query.count()
  609. skip = (page - 1) * pageSize
  610. units = query.order_by(BasProductUnit.sort_order.asc(), BasProductUnit.create_at.desc()).offset(skip).limit(pageSize).all()
  611. return success_response({
  612. "list": [{"id": u.id, "name": u.name, "code": u.code, "isActive": u.is_active, "sortOrder": u.sort_order} for u in units],
  613. "total": total,
  614. "page": page,
  615. "pageSize": pageSize,
  616. "totalPages": (total + pageSize - 1) // pageSize
  617. })
  618. @router.get("/units/active", response_model=dict)
  619. def list_active_units(
  620. db: Session = Depends(get_db),
  621. current_user: dict = Depends(require_admin)
  622. ):
  623. """获取所有启用单位"""
  624. units = unit_crud.get_active_all(db)
  625. return success_response([{"id": u.id, "name": u.name, "code": u.code} for u in units])
  626. @router.get("/units/{unit_id}", response_model=dict)
  627. def get_unit(
  628. unit_id: str,
  629. db: Session = Depends(get_db),
  630. current_user: dict = Depends(require_admin)
  631. ):
  632. """获取单位详情"""
  633. unit = unit_crud.get(db, unit_id)
  634. if not unit:
  635. return error_response(404, "单位不存在")
  636. return success_response({
  637. "id": unit.id,
  638. "name": unit.name,
  639. "code": unit.code,
  640. "isActive": unit.is_active,
  641. "sortOrder": unit.sort_order
  642. })
  643. @router.post("/units", response_model=dict)
  644. def create_unit(
  645. request: dict,
  646. db: Session = Depends(get_db),
  647. current_user: dict = Depends(require_admin)
  648. ):
  649. """创建商品单位"""
  650. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  651. if req_err is not None:
  652. return req_err
  653. # 检查编码唯一性
  654. existing = unit_crud.get_by_code(db, request.get("code"))
  655. if existing:
  656. return error_response(400, "单位编码已存在")
  657. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  658. unit = unit_crud.create(db, request, creator)
  659. return success_response({
  660. "id": unit.id,
  661. "name": unit.name,
  662. "code": unit.code,
  663. "isActive": unit.is_active,
  664. "sortOrder": unit.sort_order
  665. })
  666. @router.put("/units/{unit_id}", response_model=dict)
  667. def update_unit(
  668. unit_id: str,
  669. request: dict,
  670. db: Session = Depends(get_db),
  671. current_user: dict = Depends(require_admin)
  672. ):
  673. """更新商品单位"""
  674. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  675. if req_err is not None:
  676. return req_err
  677. # 检查编码唯一性
  678. if "code" in request and request["code"]:
  679. existing = unit_crud.get_by_code(db, request["code"])
  680. if existing and existing.id != unit_id:
  681. return error_response(400, "单位编码已存在")
  682. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  683. unit = unit_crud.update_with_history(db, unit_id, request, updater)
  684. if not unit:
  685. return error_response(404, "单位不存在")
  686. return success_response({
  687. "id": unit.id,
  688. "name": unit.name,
  689. "code": unit.code,
  690. "isActive": unit.is_active,
  691. "sortOrder": unit.sort_order
  692. })
  693. @router.delete("/units/{unit_id}", response_model=dict)
  694. def delete_unit(
  695. unit_id: str,
  696. db: Session = Depends(get_db),
  697. current_user: dict = Depends(require_admin)
  698. ):
  699. """删除商品单位"""
  700. success = unit_crud.soft_delete_with_history(db, unit_id)
  701. if not success:
  702. return error_response(404, "单位不存在")
  703. return success_response({"id": unit_id})
  704. # ============ 用户管理 ============
  705. # 角色定义常量
  706. ROLE_OPTIONS = [
  707. {"label": "超级管理员", "value": "super_admin"},
  708. {"label": "运营管理员", "value": "admin"},
  709. {"label": "供应商", "value": "supplier"},
  710. {"label": "客户", "value": "client"},
  711. {"label": "配送员", "value": "deliver"}
  712. ]
  713. def _get_user_region_scope_ids(db: Session, user_id: str) -> list[str]:
  714. rows = (
  715. db.query(SysUserRegionScope.region_id)
  716. .filter(
  717. SysUserRegionScope.user_id == user_id,
  718. SysUserRegionScope.is_del == 0,
  719. )
  720. .all()
  721. )
  722. ids = [str(row[0]).strip() for row in rows if row and row[0]]
  723. uniq = sorted({x for x in ids if x})
  724. return uniq
  725. def _batch_scope_region_ids_by_user(db: Session, user_ids: list[str]) -> dict[str, set[str]]:
  726. """批量读取用户区域授权 scope 中的 region_id(未删除),按 user_id 分组。"""
  727. if not user_ids:
  728. return {}
  729. rows = (
  730. db.query(SysUserRegionScope.user_id, SysUserRegionScope.region_id)
  731. .filter(
  732. SysUserRegionScope.user_id.in_(user_ids),
  733. SysUserRegionScope.is_del == 0,
  734. )
  735. .all()
  736. )
  737. out: dict[str, set[str]] = {}
  738. for uid, rid in rows:
  739. if not uid or not rid:
  740. continue
  741. us = str(uid).strip()
  742. rs = str(rid).strip()
  743. if not us or not rs:
  744. continue
  745. out.setdefault(us, set()).add(rs)
  746. return out
  747. def _user_display_region_ids(user: SysUser, scopes_by_user: dict[str, set[str]]) -> list[str]:
  748. """列表/详情展示用:授权 scope 中的区域 + 用户主 region_id 并集,去重排序。"""
  749. uid = str(user.id).strip()
  750. ids = set(scopes_by_user.get(uid, set()))
  751. if user.region_id:
  752. rid = str(user.region_id).strip()
  753. if rid:
  754. ids.add(rid)
  755. ids.discard("")
  756. return sorted(ids)
  757. def _join_region_names(region_ids: list[str], id_to_name: dict[str, str]) -> Optional[str]:
  758. names = [id_to_name[i] for i in region_ids if id_to_name.get(i)]
  759. return ", ".join(names) if names else None
  760. def _region_names_csv(db: Session, region_ids: list[str]) -> Optional[str]:
  761. if not region_ids:
  762. return None
  763. regs = (
  764. db.query(SysRegion.id, SysRegion.name)
  765. .filter(SysRegion.id.in_(region_ids), SysRegion.is_del == 0)
  766. .all()
  767. )
  768. m = {r.id: r.name for r in regs}
  769. return _join_region_names(region_ids, m)
  770. def _replace_user_region_scopes(
  771. db: Session,
  772. user_id: str,
  773. region_ids: list[str],
  774. operator_id: Optional[str],
  775. operator_name: Optional[str],
  776. ):
  777. current = (
  778. db.query(SysUserRegionScope)
  779. .filter(
  780. SysUserRegionScope.user_id == user_id,
  781. SysUserRegionScope.is_del == 0,
  782. )
  783. .all()
  784. )
  785. for scope in current:
  786. db.add(
  787. SysUserRegionScopeHis(
  788. id=scope.id,
  789. user_id=scope.user_id,
  790. region_id=scope.region_id,
  791. create_at=scope.create_at,
  792. create_by=scope.create_by,
  793. create_name=scope.create_name,
  794. update_at=scope.update_at,
  795. update_by=scope.update_by,
  796. )
  797. )
  798. scope.is_del = 1
  799. scope.update_by = operator_id
  800. for rid in sorted({str(x).strip() for x in (region_ids or []) if str(x).strip()}):
  801. db.add(
  802. SysUserRegionScope(
  803. id=generate_id(),
  804. user_id=user_id,
  805. region_id=rid,
  806. is_del=0,
  807. create_by=operator_id,
  808. create_name=operator_name,
  809. update_by=operator_id,
  810. )
  811. )
  812. @router.get("/roles", response_model=dict)
  813. def list_roles(
  814. current_user: dict = Depends(require_admin)
  815. ):
  816. """获取角色列表"""
  817. return success_response(ROLE_OPTIONS)
  818. @router.get("/menus/assignable-codes", response_model=dict)
  819. def list_assignable_menu_codes(
  820. db: Session = Depends(get_db),
  821. current_user: dict = Depends(require_admin)
  822. ):
  823. """可授权菜单 code 列表(仅 menu 类型)。"""
  824. menus = (
  825. db.query(SysMenu)
  826. .filter(
  827. SysMenu.is_del == 0,
  828. SysMenu.is_active == 1,
  829. SysMenu.menu_type == "menu",
  830. SysMenu.code.isnot(None),
  831. )
  832. .order_by(SysMenu.sort_order.asc())
  833. .all()
  834. )
  835. return success_response({
  836. "list": [
  837. {
  838. "id": m.id,
  839. "code": m.code,
  840. "name": m.name,
  841. "parentId": m.parent_id,
  842. "sortOrder": m.sort_order,
  843. }
  844. for m in menus
  845. ]
  846. })
  847. @router.get("/users/{user_id}/menu-scopes", response_model=dict)
  848. def get_user_menu_scopes(
  849. user_id: str,
  850. db: Session = Depends(get_db),
  851. current_user: dict = Depends(require_admin)
  852. ):
  853. """查询用户菜单授权 code 列表。"""
  854. user = user_crud.get(db, user_id)
  855. if not user:
  856. return error_response(404, "用户不存在")
  857. rows = (
  858. db.query(SysUserMenuScope.menu_code)
  859. .filter(
  860. SysUserMenuScope.user_id == user_id,
  861. SysUserMenuScope.is_del == 0,
  862. )
  863. .all()
  864. )
  865. codes = sorted({str(row[0]).strip() for row in rows if row and row[0]})
  866. return success_response({
  867. "userId": user_id,
  868. "menuCodes": codes
  869. })
  870. @router.put("/users/{user_id}/menu-scopes", response_model=dict)
  871. def put_user_menu_scopes(
  872. user_id: str,
  873. request: dict,
  874. db: Session = Depends(get_db),
  875. current_user: dict = Depends(require_admin)
  876. ):
  877. """更新用户菜单授权(按 code 全量覆盖;兼容明文/密文载荷)。"""
  878. return _update_user_menu_scopes(user_id, request, db, current_user)
  879. @router.post("/users/{user_id}/menu-scopes", response_model=dict)
  880. def post_user_menu_scopes(
  881. user_id: str,
  882. request: dict,
  883. db: Session = Depends(get_db),
  884. current_user: dict = Depends(require_admin)
  885. ):
  886. """与 PUT 同逻辑:前端按 POST 模式调用时复用。"""
  887. return _update_user_menu_scopes(user_id, request, db, current_user)
  888. def _update_user_menu_scopes(user_id: str, request: dict, db: Session, current_user: dict):
  889. """更新用户菜单授权(按 code 全量覆盖;兼容明文/密文载荷)。"""
  890. from app.core import api_crypto
  891. from app.core.config import settings
  892. user = user_crud.get(db, user_id)
  893. if not user:
  894. return error_response(404, "用户不存在")
  895. if user.role == "super_admin":
  896. return error_response(400, "超级管理员无需配置菜单权限")
  897. req_keys: list[str] = []
  898. try:
  899. req_keys = list((request or {}).keys())
  900. except Exception:
  901. req_keys = []
  902. req_dict = request or {}
  903. # 明文载荷:{ menuCodes: [...] }
  904. raw_codes = req_dict.get("menuCodes", [])
  905. # 密文信封:{ enc, ts, nonce, edata }
  906. # 若信封里没有 menuCodes,则尝试解密回退。
  907. if (
  908. isinstance(req_dict, dict)
  909. and req_dict.get("enc") == 1
  910. and "menuCodes" not in req_dict
  911. and req_dict.get("edata")
  912. ):
  913. try:
  914. ts = req_dict.get("ts")
  915. nonce = req_dict.get("nonce")
  916. edata = req_dict.get("edata")
  917. fixed_secret = getattr(settings, "API_ENCRYPT_FIXED_SECRET", "") or ""
  918. operator_user_id = current_user.get("user_id") or ""
  919. if not (fixed_secret and operator_user_id and ts is not None and nonce):
  920. return error_response(400, "密文解析配置缺失")
  921. key = api_crypto.derive_key(
  922. fixed_secret=fixed_secret,
  923. user_id=str(operator_user_id),
  924. ts=int(ts),
  925. nonce=str(nonce),
  926. )
  927. plain = api_crypto.decrypt_json(str(edata), key)
  928. payload = plain.get("data", plain) if isinstance(plain, dict) else {}
  929. raw_codes = payload.get("menuCodes", []) if isinstance(payload, dict) else []
  930. except Exception as exc:
  931. return error_response(400, f"密文解密失败: {exc}")
  932. if raw_codes is None:
  933. raw_codes = []
  934. if not isinstance(raw_codes, list):
  935. return error_response(400, "menuCodes 必须为数组")
  936. menu_codes = sorted({str(code).strip() for code in raw_codes if str(code).strip()})
  937. desired_codes_set = {str(code).strip() for code in menu_codes if str(code).strip()}
  938. # 调试:定位前端提交是否真的到后端(避免把全量内容写日志,保留前几个)
  939. try:
  940. preview = menu_codes[:10]
  941. except Exception:
  942. preview = []
  943. print(
  944. f"[menu-scope-save] user_id={user_id} req_keys={req_keys} "
  945. f"menu_codes_len={len(menu_codes)} preview={preview}"
  946. )
  947. operator_id = current_user.get("user_id")
  948. operator_name = current_user.get("real_name")
  949. # sys_user_menu_scope 唯一约束:uk_user_menu_scope (user_id, menu_code, is_del)
  950. # 这意味着同一 (user_id, menu_code) 可能同时存在 is_del=0 与 is_del=1 两条记录。
  951. # 当我们需要把某条从 1 更新为 0(或相反)时,可能会撞唯一键,因此需要先“归一化”:
  952. # - 目标授权: 最终只保留 is_del=0
  953. # - 非目标: 最终只保留 is_del=1(允许该 code 不存在)
  954. current_scopes = db.query(SysUserMenuScope).filter(SysUserMenuScope.user_id == user_id).all()
  955. scopes_by_code_and_del: dict[str, dict[int, SysUserMenuScope]] = {}
  956. for scope in current_scopes:
  957. code = str(scope.menu_code).strip()
  958. del_key = int(scope.is_del or 0)
  959. scopes_by_code_and_del.setdefault(code, {})[del_key] = scope
  960. def _add_scope_his(scope: SysUserMenuScope):
  961. db.add(
  962. SysUserMenuScopeHis(
  963. id=scope.id,
  964. user_id=scope.user_id,
  965. menu_code=scope.menu_code,
  966. create_at=scope.create_at,
  967. create_by=scope.create_by,
  968. create_name=scope.create_name,
  969. update_at=scope.update_at,
  970. update_by=scope.update_by,
  971. )
  972. )
  973. all_codes = set(scopes_by_code_and_del.keys()) | desired_codes_set
  974. for code in all_codes:
  975. target_is_del = 0 if code in desired_codes_set else 1
  976. bucket = scopes_by_code_and_del.get(code) or {}
  977. scope0 = bucket.get(0)
  978. scope1 = bucket.get(1)
  979. if target_is_del == 0:
  980. # 需要“未删除态”
  981. if scope0 and scope1:
  982. # 两种状态都存在:保留 is_del=0,删除 is_del=1 那条(否则无法通过 update 归一化)
  983. _add_scope_his(scope1)
  984. db.delete(scope1)
  985. elif scope0:
  986. # 仅 is_del=0 存在:只更新审计字段
  987. scope0.update_by = operator_id
  988. elif scope1:
  989. # 仅 is_del=1 存在:恢复为 is_del=0
  990. _add_scope_his(scope1)
  991. scope1.is_del = 0
  992. scope1.update_by = operator_id
  993. else:
  994. # 两条都不存在:新增 is_del=0
  995. db.add(
  996. SysUserMenuScope(
  997. id=generate_id(),
  998. user_id=user_id,
  999. menu_code=code,
  1000. is_del=0,
  1001. create_by=operator_id,
  1002. create_name=operator_name,
  1003. update_by=operator_id,
  1004. )
  1005. )
  1006. else:
  1007. # 需要“删除态” (is_del=1)
  1008. if scope0 and scope1:
  1009. # 两种状态都存在:保留 is_del=1,删除 is_del=0 那条
  1010. _add_scope_his(scope0)
  1011. db.delete(scope0)
  1012. elif scope1:
  1013. # 仅 is_del=1 存在:只更新审计字段
  1014. scope1.update_by = operator_id
  1015. elif scope0:
  1016. # 仅 is_del=0 存在:置为 is_del=1
  1017. _add_scope_his(scope0)
  1018. scope0.is_del = 1
  1019. scope0.update_by = operator_id
  1020. else:
  1021. # 目标是删除态但表中不存在:无需新增一条 is_del=1 记录
  1022. pass
  1023. db.commit()
  1024. return success_response({"userId": user_id, "menuCodes": menu_codes})
  1025. @router.get("/users", response_model=dict)
  1026. def list_users(
  1027. page: int = Query(1, ge=1, description="页码"),
  1028. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  1029. role: Optional[str] = Query(None, description="角色筛选"),
  1030. roleIn: Optional[str] = Query(None, description="角色集合筛选,逗号分隔"),
  1031. regionId: Optional[str] = Query(None, description="区域ID"),
  1032. keyword: Optional[str] = Query(None, description="关键词搜索"),
  1033. isActive: Optional[bool] = Query(None, description="是否激活"),
  1034. db: Session = Depends(get_db),
  1035. current_user: dict = Depends(require_admin)
  1036. ):
  1037. """获取用户列表"""
  1038. role_in_values: Optional[list[str]] = None
  1039. if roleIn:
  1040. role_in_values = [x.strip() for x in str(roleIn).split(",") if x and x.strip()]
  1041. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  1042. if allowed_region_ids is not None:
  1043. if regionId and regionId not in allowed_region_ids:
  1044. return error_response(403, "无权访问该区域数据")
  1045. if not regionId and allowed_region_ids:
  1046. regionId = sorted(allowed_region_ids)[0]
  1047. users, total = user_crud.get_multi_with_filters(
  1048. db,
  1049. role=role,
  1050. roles=role_in_values,
  1051. region_id=regionId,
  1052. keyword=keyword,
  1053. is_active=isActive,
  1054. skip=(page - 1) * pageSize,
  1055. limit=pageSize
  1056. )
  1057. uid_list = [u.id for u in users]
  1058. scopes_by_user = _batch_scope_region_ids_by_user(db, uid_list)
  1059. all_region_ids: set[str] = set()
  1060. for u in users:
  1061. all_region_ids.update(_user_display_region_ids(u, scopes_by_user))
  1062. id_to_name: dict[str, str] = {}
  1063. if all_region_ids:
  1064. regs = (
  1065. db.query(SysRegion.id, SysRegion.name)
  1066. .filter(SysRegion.id.in_(list(all_region_ids)), SysRegion.is_del == 0)
  1067. .all()
  1068. )
  1069. id_to_name = {r.id: r.name for r in regs}
  1070. return success_response({
  1071. "list": [{
  1072. "id": u.id,
  1073. "username": u.username,
  1074. "realName": u.real_name,
  1075. "phone": u.phone,
  1076. "avatarUrl": (str(u.avatar_url).strip() if getattr(u, "avatar_url", None) else None) or None,
  1077. "role": u.role,
  1078. "regionId": u.region_id,
  1079. "regionName": _join_region_names(_user_display_region_ids(u, scopes_by_user), id_to_name),
  1080. "regionIds": sorted(scopes_by_user.get(str(u.id).strip(), set())),
  1081. "isActive": u.is_active,
  1082. "createAt": str(u.create_at) if u.create_at else None,
  1083. "createName": getattr(u, "create_name", None)
  1084. } for u in users],
  1085. "total": total,
  1086. "page": page,
  1087. "pageSize": pageSize,
  1088. "totalPages": (total + pageSize - 1) // pageSize
  1089. })
  1090. @router.get("/users/{user_id}", response_model=dict)
  1091. def get_user(
  1092. user_id: str,
  1093. db: Session = Depends(get_db),
  1094. current_user: dict = Depends(require_admin)
  1095. ):
  1096. """获取用户详情"""
  1097. user = user_crud.get(db, user_id)
  1098. if not user:
  1099. return error_response(404, "用户不存在")
  1100. scopes_by_user = _batch_scope_region_ids_by_user(db, [user.id])
  1101. region_name = _region_names_csv(db, _user_display_region_ids(user, scopes_by_user))
  1102. return success_response({
  1103. "id": user.id,
  1104. "username": user.username,
  1105. "realName": user.real_name,
  1106. "phone": user.phone,
  1107. "avatarUrl": (str(user.avatar_url).strip() if getattr(user, "avatar_url", None) else None) or None,
  1108. "role": user.role,
  1109. "regionId": user.region_id,
  1110. "regionName": region_name,
  1111. "regionIds": _get_user_region_scope_ids(db, user.id),
  1112. "isActive": user.is_active,
  1113. "createAt": str(user.create_at) if user.create_at else None,
  1114. "createName": getattr(user, "create_name", None)
  1115. })
  1116. @router.post("/users", response_model=dict)
  1117. def create_user(
  1118. request: dict,
  1119. db: Session = Depends(get_db),
  1120. current_user: dict = Depends(require_admin)
  1121. ):
  1122. """创建用户"""
  1123. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  1124. if req_err is not None:
  1125. return req_err
  1126. username = (request.get("username") or "").strip() if isinstance(request, dict) else ""
  1127. if not username:
  1128. return error_response(400, "用户名不能为空")
  1129. # uk_username 覆盖软删行:活跃账号直接拒绝;已软删则释放用户名后允许重建
  1130. existing = user_crud.get_by_username_any(db, username)
  1131. if existing:
  1132. if getattr(existing, "is_del", 0) == 0:
  1133. return error_response(400, "用户名已存在")
  1134. existing.username = user_crud._release_username(existing.username, existing.id)
  1135. db.flush()
  1136. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  1137. body = dict(request) if request else {}
  1138. body["username"] = username
  1139. region_ids = body.pop("regionIds", None)
  1140. if body.get("avatarUrl") == "":
  1141. body["avatarUrl"] = None
  1142. try:
  1143. user, initial_password = user_crud.create_with_password(db, body, creator)
  1144. except ValueError as e:
  1145. return error_response(400, str(e))
  1146. except IntegrityError as e:
  1147. db.rollback()
  1148. err = str(getattr(e.orig, "args", e) or e)
  1149. if "uk_username" in err or "username" in err.lower():
  1150. return error_response(400, "用户名已存在")
  1151. raise
  1152. if isinstance(region_ids, list):
  1153. _replace_user_region_scopes(db, user.id, region_ids, current_user.get("user_id"), current_user.get("real_name"))
  1154. db.commit()
  1155. payload = {
  1156. "id": user.id,
  1157. "username": user.username,
  1158. "realName": user.real_name,
  1159. "avatarUrl": (str(user.avatar_url).strip() if getattr(user, "avatar_url", None) else None) or None,
  1160. "regionIds": _get_user_region_scope_ids(db, user.id),
  1161. }
  1162. if initial_password is not None:
  1163. payload["initialPassword"] = initial_password
  1164. return success_response(payload)
  1165. @router.put("/users/{user_id}", response_model=dict)
  1166. def update_user(
  1167. user_id: str,
  1168. request: dict,
  1169. db: Session = Depends(get_db),
  1170. current_user: dict = Depends(require_admin)
  1171. ):
  1172. """更新用户"""
  1173. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  1174. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  1175. if req_err is not None:
  1176. return req_err
  1177. req = dict(request) if request else {}
  1178. region_ids = req.pop("regionIds", None)
  1179. if req.get("avatarUrl") == "":
  1180. req["avatarUrl"] = None
  1181. user = user_crud.update_with_history(db, user_id, req, updater)
  1182. if not user:
  1183. return error_response(404, "用户不存在")
  1184. if isinstance(region_ids, list):
  1185. _replace_user_region_scopes(db, user_id, region_ids, current_user.get("user_id"), current_user.get("real_name"))
  1186. db.commit()
  1187. scopes_by_user = _batch_scope_region_ids_by_user(db, [user.id])
  1188. region_name = _region_names_csv(db, _user_display_region_ids(user, scopes_by_user))
  1189. return success_response({
  1190. "id": user.id,
  1191. "username": user.username,
  1192. "realName": user.real_name,
  1193. "phone": user.phone,
  1194. "avatarUrl": (str(user.avatar_url).strip() if getattr(user, "avatar_url", None) else None) or None,
  1195. "role": user.role,
  1196. "regionId": user.region_id,
  1197. "regionName": region_name,
  1198. "regionIds": _get_user_region_scope_ids(db, user.id),
  1199. "isActive": user.is_active
  1200. })
  1201. @router.delete("/users/{user_id}", response_model=dict)
  1202. def delete_user(
  1203. user_id: str,
  1204. db: Session = Depends(get_db),
  1205. current_user: dict = Depends(require_admin)
  1206. ):
  1207. """删除用户"""
  1208. # 不能删除自己
  1209. if user_id == current_user.get("user_id"):
  1210. return error_response(400, "不能删除当前登录账号")
  1211. success = user_crud.soft_delete_with_history(db, user_id)
  1212. if not success:
  1213. return error_response(404, "用户不存在")
  1214. return success_response({"id": user_id})
  1215. @router.post("/users/upload-avatar", response_model=dict)
  1216. async def upload_user_avatar(
  1217. file: UploadFile = File(...),
  1218. folder: str = Form("avatars"),
  1219. current_user: dict = Depends(require_admin),
  1220. ):
  1221. """上传用户头像图片,返回可写入 avatarUrl 的地址"""
  1222. try:
  1223. content = await validate_file(file, MAX_IMAGE_SIZE, ALLOWED_IMAGE_TYPES)
  1224. original_filename = file.filename or "avatar.png"
  1225. url = storage.upload_file(content, original_filename, folder)
  1226. return success_response({"url": url})
  1227. except HTTPException:
  1228. raise
  1229. except Exception as e:
  1230. return error_response(500, f"上传失败: {str(e)}")
  1231. @router.post("/users/{user_id}/reset-password", response_model=dict)
  1232. def reset_user_password(
  1233. user_id: str,
  1234. request: Request,
  1235. db: Session = Depends(get_db),
  1236. current_user: dict = Depends(require_admin)
  1237. ):
  1238. """重置用户密码为服务端固定默认(明文不由请求传入,响应中也不返回密码)"""
  1239. try:
  1240. success = user_crud.reset_password_to_admin_default(db, user_id)
  1241. except ValueError as e:
  1242. return error_response(400, str(e))
  1243. if not success:
  1244. return error_response(404, "用户不存在")
  1245. ip_address = get_client_ip(request)
  1246. password_history_crud.create_password_history(
  1247. db,
  1248. user_id=user_id,
  1249. operation_type="reset",
  1250. operator_id=current_user.get("user_id"),
  1251. operator_name=current_user.get("real_name"),
  1252. ip_address=ip_address
  1253. )
  1254. return success_response({"ok": True})
  1255. @router.get("/users/{user_id}/password-history", response_model=dict)
  1256. def get_user_password_history(
  1257. user_id: str,
  1258. page: int = Query(1, ge=1, description="页码"),
  1259. page_size: int = Query(10, ge=1, le=50, description="每页条数"),
  1260. db: Session = Depends(get_db),
  1261. current_user: dict = Depends(require_admin)
  1262. ):
  1263. """获取用户密码修改历史记录"""
  1264. try:
  1265. from app.models.models import SysPasswordHistory
  1266. query = db.query(SysPasswordHistory).filter(
  1267. SysPasswordHistory.user_id == user_id,
  1268. SysPasswordHistory.is_del == 0
  1269. )
  1270. total = query.count()
  1271. items = query.order_by(SysPasswordHistory.create_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
  1272. result = []
  1273. for item in items:
  1274. result.append({
  1275. "id": item.id,
  1276. "userId": item.user_id,
  1277. "operationType": item.operation_type,
  1278. "operationTypeText": "重置密码" if item.operation_type == "reset" else "修改密码" if item.operation_type == "change" else item.operation_type,
  1279. "operatorName": item.operator_name,
  1280. "operatorId": item.operator_id,
  1281. "ipAddress": item.ip_address,
  1282. "createAt": str(item.create_at) if item.create_at else None
  1283. })
  1284. return success_response({
  1285. "list": result,
  1286. "total": total,
  1287. "page": page,
  1288. "pageSize": page_size
  1289. })
  1290. except Exception as e:
  1291. return error_response(500, f"获取密码历史失败: {str(e)}")
  1292. @router.get("/password-history", response_model=dict)
  1293. def get_password_history_list(
  1294. page: int = Query(1, ge=1, description="页码"),
  1295. page_size: int = Query(10, ge=1, le=50, description="每页条数"),
  1296. user_id: str = Query(None, description="用户ID"),
  1297. operation_type: str = Query(None, description="操作类型"),
  1298. keyword: str = Query(None, description="关键词搜索"),
  1299. db: Session = Depends(get_db),
  1300. current_user: dict = Depends(require_admin)
  1301. ):
  1302. """获取密码修改历史记录列表(后台管理用)"""
  1303. try:
  1304. from app.models.models import SysPasswordHistory
  1305. query = db.query(SysPasswordHistory).filter(SysPasswordHistory.is_del == 0)
  1306. if user_id:
  1307. query = query.filter(SysPasswordHistory.user_id == user_id)
  1308. if operation_type:
  1309. query = query.filter(SysPasswordHistory.operation_type == operation_type)
  1310. if keyword:
  1311. query = query.filter(SysPasswordHistory.operator_name.like(f"%{keyword}%"))
  1312. total = query.count()
  1313. items = query.order_by(SysPasswordHistory.create_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
  1314. result = []
  1315. for item in items:
  1316. # 获取用户信息
  1317. user_info = {}
  1318. user = user_crud.get(db, item.user_id)
  1319. if user:
  1320. user_info = {
  1321. "userName": user.username,
  1322. "realName": user.real_name,
  1323. "role": user.role
  1324. }
  1325. result.append({
  1326. "id": item.id,
  1327. "userId": item.user_id,
  1328. "userInfo": user_info,
  1329. "operationType": item.operation_type,
  1330. "operationTypeText": "重置密码" if item.operation_type == "reset" else "修改密码" if item.operation_type == "change" else item.operation_type,
  1331. "operatorName": item.operator_name,
  1332. "operatorId": item.operator_id,
  1333. "ipAddress": item.ip_address,
  1334. "createAt": str(item.create_at) if item.create_at else None
  1335. })
  1336. return success_response({
  1337. "list": result,
  1338. "total": total,
  1339. "page": page,
  1340. "pageSize": page_size
  1341. })
  1342. except Exception as e:
  1343. return error_response(500, f"获取密码历史失败: {str(e)}")
  1344. @router.post("/users/change-password", response_model=dict)
  1345. def change_password(
  1346. body: dict,
  1347. request: Request,
  1348. db: Session = Depends(get_db),
  1349. current_user: dict = Depends(get_current_user)
  1350. ):
  1351. """用户修改密码"""
  1352. old_password = body.get("oldPassword")
  1353. new_password = body.get("newPassword")
  1354. if not old_password or not new_password:
  1355. return error_response(400, "旧密码和新密码都不能为空")
  1356. if len(new_password) < 6:
  1357. return error_response(400, "新密码长度不能少于6位")
  1358. user = user_crud.get(db, current_user.get("user_id"))
  1359. if not user:
  1360. return error_response(404, "用户不存在")
  1361. if not verify_password(old_password, user.password_hash):
  1362. return error_response(400, "旧密码错误")
  1363. user.password_hash = get_password_hash(new_password)
  1364. db.commit()
  1365. ip_address = get_client_ip(request)
  1366. password_history_crud.create_password_history(
  1367. db,
  1368. user_id=user.id,
  1369. operation_type="modify",
  1370. operator_id=user.id,
  1371. operator_name=user.real_name,
  1372. ip_address=ip_address
  1373. )
  1374. return success_response({"message": "密码修改成功"})
  1375. # ============ 商品管理 ============
  1376. @router.get("/products", response_model=dict)
  1377. def list_products(
  1378. page: int = Query(1, ge=1, description="页码"),
  1379. pageSize: int = Query(10, ge=1, le=1000, description="每页条数"),
  1380. categoryId: Optional[str] = Query(None, description="分类ID"),
  1381. keyword: Optional[str] = Query(None, description="关键词搜索"),
  1382. isActive: Optional[bool] = Query(None, description="是否启用"),
  1383. db: Session = Depends(get_db),
  1384. current_user: dict = Depends(require_admin)
  1385. ):
  1386. """获取商品列表"""
  1387. products, total = product_crud.get_multi_with_filters(
  1388. db,
  1389. category_id=categoryId,
  1390. keyword=keyword,
  1391. is_active=isActive,
  1392. skip=(page - 1) * pageSize,
  1393. limit=pageSize,
  1394. defer_detail_content=True,
  1395. )
  1396. category_ids = {p.category_id for p in products if p.category_id}
  1397. unit_ids = {p.unit_id for p in products if p.unit_id}
  1398. category_map: dict[str, str] = {}
  1399. unit_map: dict[str, str] = {}
  1400. if category_ids:
  1401. rows = (
  1402. db.query(BasProductCategory.id, BasProductCategory.name)
  1403. .filter(BasProductCategory.id.in_(category_ids))
  1404. .all()
  1405. )
  1406. category_map = {str(r.id): r.name for r in rows}
  1407. if unit_ids:
  1408. rows = (
  1409. db.query(BasProductUnit.id, BasProductUnit.name)
  1410. .filter(BasProductUnit.id.in_(unit_ids))
  1411. .all()
  1412. )
  1413. unit_map = {str(r.id): r.name for r in rows}
  1414. return success_response({
  1415. "list": [{
  1416. "id": p.id,
  1417. "name": p.name,
  1418. "skuCode": p.sku_code,
  1419. "categoryId": p.category_id,
  1420. "categoryName": category_map.get(str(p.category_id)) if p.category_id else None,
  1421. "unitId": p.unit_id,
  1422. "unitName": unit_map.get(str(p.unit_id)) if p.unit_id else None,
  1423. "measureValue": _numeric_to_float_optional(getattr(p, "measure_value", None)),
  1424. "spec": getattr(p, "spec", None) or None,
  1425. "description": p.description,
  1426. # 列表不加载 detail_content(TEXT);详情见 GET /products/{id}
  1427. "detailContent": None,
  1428. "imageUrl": p.image_url,
  1429. "priceCoefficient": _numeric_to_float_optional(getattr(p, "price_coefficient", None)) or 0.0,
  1430. "suggestedSupplierPrice": _numeric_to_float_optional(getattr(p, "suggested_supplier_price", None)),
  1431. "isActive": p.is_active,
  1432. "createAt": str(p.create_at) if p.create_at else None
  1433. } for p in products],
  1434. "total": total,
  1435. "page": page,
  1436. "pageSize": pageSize,
  1437. "totalPages": (total + pageSize - 1) // pageSize
  1438. })
  1439. @router.get("/products/{product_id}", response_model=dict)
  1440. def get_product(
  1441. product_id: str,
  1442. db: Session = Depends(get_db),
  1443. current_user: dict = Depends(require_admin)
  1444. ):
  1445. """获取商品详情"""
  1446. product = product_crud.get(db, product_id)
  1447. if not product:
  1448. return error_response(404, "商品不存在")
  1449. category_name = None
  1450. if product.category_id:
  1451. cat = category_crud.get(db, product.category_id)
  1452. category_name = cat.name if cat else None
  1453. unit_name = None
  1454. if product.unit_id:
  1455. u = unit_crud.get(db, product.unit_id)
  1456. unit_name = u.name if u else None
  1457. return success_response({
  1458. "id": product.id,
  1459. "name": product.name,
  1460. "skuCode": product.sku_code,
  1461. "categoryId": product.category_id,
  1462. "categoryName": category_name,
  1463. "unitId": product.unit_id,
  1464. "unitName": unit_name,
  1465. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)),
  1466. "spec": product.spec or None,
  1467. "description": product.description,
  1468. "detailContent": product.detail_content,
  1469. "imageUrl": product.image_url,
  1470. "priceCoefficient": _numeric_to_float_optional(getattr(product, "price_coefficient", None)) or 0.0,
  1471. "suggestedSupplierPrice": _numeric_to_float_optional(getattr(product, "suggested_supplier_price", None)),
  1472. "isActive": product.is_active,
  1473. "createAt": str(product.create_at) if product.create_at else None
  1474. })
  1475. @router.get("/products/{product_id}/region-prices", response_model=dict)
  1476. def get_product_region_prices(
  1477. product_id: str,
  1478. db: Session = Depends(get_db),
  1479. current_user: dict = Depends(require_admin)
  1480. ):
  1481. """各启用区域下,当前生效价格清单中该商品的单价(无生效价则 price 为空)"""
  1482. from app.crud.price_list import price_list_item_crud
  1483. product = product_crud.get(db, product_id)
  1484. if not product:
  1485. return error_response(404, "商品不存在")
  1486. regions = region_crud.get_active_all(db)
  1487. admin_region_id = get_admin_region_filter(db, current_user)
  1488. if admin_region_id:
  1489. regions = [r for r in regions if r.id == admin_region_id]
  1490. rows = []
  1491. for r in regions:
  1492. item = price_list_item_crud.get_product_price(db, r.id, product_id)
  1493. if item:
  1494. unit_name = None
  1495. if item.unit_id:
  1496. u = unit_crud.get(db, item.unit_id)
  1497. unit_name = u.name if u else None
  1498. rows.append({
  1499. "regionId": r.id,
  1500. "regionName": r.name,
  1501. "price": float(item.price) if item.price is not None else None,
  1502. "unitId": item.unit_id,
  1503. "unitName": unit_name
  1504. })
  1505. else:
  1506. rows.append({
  1507. "regionId": r.id,
  1508. "regionName": r.name,
  1509. "price": None,
  1510. "unitId": None,
  1511. "unitName": None
  1512. })
  1513. return success_response({"list": rows})
  1514. @router.post("/products", response_model=dict)
  1515. def create_product(
  1516. request: dict,
  1517. db: Session = Depends(get_db),
  1518. current_user: dict = Depends(require_admin)
  1519. ):
  1520. """创建商品"""
  1521. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  1522. if req_err is not None:
  1523. return req_err
  1524. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  1525. if "spec" in request:
  1526. request["spec"] = normalize_product_spec(request.get("spec"))
  1527. if "suggestedSupplierPrice" in request or "suggested_supplier_price" in request:
  1528. raw = request.get("suggestedSupplierPrice", request.get("suggested_supplier_price"))
  1529. try:
  1530. normalized = normalize_suggested_supplier_price(raw)
  1531. except ValueError as e:
  1532. return error_response(400, str(e))
  1533. request["suggestedSupplierPrice"] = float(normalized) if normalized is not None else None
  1534. request.pop("suggested_supplier_price", None)
  1535. try:
  1536. product = product_crud.create(db, request, creator)
  1537. except IntegrityError as e:
  1538. db.rollback()
  1539. err = str(getattr(e.orig, "args", e) or e)
  1540. if "uk_sku_code" in err or "sku_code" in err.lower():
  1541. return error_response(400, "SKU 编码已存在,请修改或点击「自动生成」")
  1542. raise
  1543. return success_response({
  1544. "id": product.id,
  1545. "name": product.name
  1546. })
  1547. @router.post("/products/upload-image", response_model=dict)
  1548. async def upload_product_image(
  1549. file: UploadFile = File(...),
  1550. db: Session = Depends(get_db),
  1551. current_user: dict = Depends(require_admin)
  1552. ):
  1553. """上传商品图片/轮播图(支持图片和视频)"""
  1554. try:
  1555. # 验证文件类型
  1556. allowed_image_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif"}
  1557. allowed_video_types = {"video/mp4", "video/quicktime", "video/webm", "video/x-msvideo"}
  1558. allowed_types = allowed_image_types | allowed_video_types
  1559. if file.content_type not in allowed_types:
  1560. return error_response(400, "不支持的文件格式,支持:JPG、PNG、GIF、WebP、HEIC、MP4")
  1561. # 验证文件大小 (图片10MB,视频100MB)
  1562. max_size = 100 * 1024 * 1024 if file.content_type.startswith("video/") else 10 * 1024 * 1024
  1563. content = b""
  1564. chunk_size = 1024 * 1024
  1565. while True:
  1566. chunk = await file.read(chunk_size)
  1567. if not chunk:
  1568. break
  1569. content += chunk
  1570. if len(content) > max_size:
  1571. return error_response(400, f"{'视频' if file.content_type.startswith('video/') else '图片'}大小不能超过{'100MB' if file.content_type.startswith('video/') else '10MB'}")
  1572. await file.seek(0)
  1573. # 确定文件后缀和上传目录
  1574. if file.content_type.startswith("video/"):
  1575. ext = "mp4" if file.content_type == "video/mp4" else "mov" if file.content_type == "video/quicktime" else "webm" if file.content_type == "video/webm" else "avi"
  1576. folder = "products/banners"
  1577. filename = f"video_{uuid.uuid4().hex[:8]}.{ext}"
  1578. else:
  1579. ext = file.filename.rsplit(".", 1)[-1] if "." in (file.filename or "") else "jpg"
  1580. folder = "products"
  1581. filename = file.filename or f"image_{uuid.uuid4().hex[:8]}.{ext}"
  1582. # 上传文件
  1583. url = storage.upload_file(content, filename, folder)
  1584. return success_response({
  1585. "url": url,
  1586. "filename": file.filename,
  1587. "contentType": file.content_type,
  1588. "size": len(content)
  1589. })
  1590. except Exception as e:
  1591. return error_response(500, f"上传失败: {str(e)}")
  1592. @router.post("/products/upload-base64", response_model=dict)
  1593. async def upload_product_image_base64(
  1594. data: dict,
  1595. db: Session = Depends(get_db),
  1596. current_user: dict = Depends(require_admin)
  1597. ):
  1598. """上传商品图片(Base64方式)"""
  1599. try:
  1600. base64_data = data.get("base64")
  1601. if not base64_data:
  1602. return error_response(400, "base64数据不能为空")
  1603. # 去除前缀
  1604. if "," in base64_data:
  1605. base64_data = base64_data.split(",")[1]
  1606. # 验证图片大小
  1607. decoded_len = len(base64_data) * 3 // 4
  1608. if decoded_len > 5 * 1024 * 1024:
  1609. return error_response(400, "图片大小不能超过5MB")
  1610. url = storage.upload_base64(base64_data, "product.png", "products")
  1611. return success_response({
  1612. "url": url,
  1613. "filename": "product.png"
  1614. })
  1615. except Exception as e:
  1616. return error_response(500, f"上传失败: {str(e)}")
  1617. @router.put("/products/{product_id}", response_model=dict)
  1618. def update_product(
  1619. product_id: str,
  1620. request: dict,
  1621. db: Session = Depends(get_db),
  1622. current_user: dict = Depends(require_admin)
  1623. ):
  1624. """更新商品"""
  1625. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  1626. if req_err is not None:
  1627. return req_err
  1628. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  1629. if "spec" in request:
  1630. request["spec"] = normalize_product_spec(request.get("spec"))
  1631. if "suggestedSupplierPrice" in request or "suggested_supplier_price" in request:
  1632. raw = request.get("suggestedSupplierPrice", request.get("suggested_supplier_price"))
  1633. try:
  1634. normalized = normalize_suggested_supplier_price(raw)
  1635. except ValueError as e:
  1636. return error_response(400, str(e))
  1637. request["suggestedSupplierPrice"] = float(normalized) if normalized is not None else None
  1638. request.pop("suggested_supplier_price", None)
  1639. product = product_crud.update_with_history(db, product_id, request, updater)
  1640. if not product:
  1641. return error_response(404, "商品不存在")
  1642. return success_response({
  1643. "id": product.id,
  1644. "name": product.name,
  1645. "skuCode": product.sku_code,
  1646. "categoryId": product.category_id,
  1647. "unitId": product.unit_id,
  1648. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)),
  1649. "spec": product.spec or None,
  1650. "description": product.description,
  1651. "detailContent": product.detail_content,
  1652. "imageUrl": product.image_url,
  1653. "priceCoefficient": _numeric_to_float_optional(getattr(product, "price_coefficient", None)) or 0.0,
  1654. "suggestedSupplierPrice": _numeric_to_float_optional(getattr(product, "suggested_supplier_price", None)),
  1655. "isActive": product.is_active
  1656. })
  1657. @router.delete("/products/{product_id}", response_model=dict)
  1658. def delete_product(
  1659. product_id: str,
  1660. db: Session = Depends(get_db),
  1661. current_user: dict = Depends(require_admin)
  1662. ):
  1663. """删除商品"""
  1664. success = product_crud.soft_delete_with_history(db, product_id)
  1665. if not success:
  1666. return error_response(404, "商品不存在")
  1667. return success_response({"id": product_id})
  1668. # ============ 询价单管理 ============
  1669. @router.get("/inquiries", response_model=dict)
  1670. def list_inquiries(
  1671. page: int = Query(1, ge=1, description="页码"),
  1672. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  1673. regionId: Optional[str] = Query(None, description="区域ID"),
  1674. status: Optional[str] = Query(None, description="状态: draft/published/closed"),
  1675. keyword: Optional[str] = Query(None, description="关键词搜索"),
  1676. db: Session = Depends(get_db),
  1677. current_user: dict = Depends(require_admin)
  1678. ):
  1679. """获取询价单列表"""
  1680. from app.crud.inquiry import inquiry_crud
  1681. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  1682. if allowed_region_ids is not None:
  1683. if regionId and regionId not in allowed_region_ids:
  1684. return error_response(403, "无权访问该区域数据")
  1685. if not regionId and allowed_region_ids:
  1686. regionId = sorted(allowed_region_ids)[0]
  1687. inquiries, total = inquiry_crud.get_multi_with_filters(
  1688. db,
  1689. region_id=regionId,
  1690. status=status,
  1691. keyword=keyword,
  1692. skip=(page - 1) * pageSize,
  1693. limit=pageSize
  1694. )
  1695. region_map = {}
  1696. for inquiry in inquiries:
  1697. if inquiry.region_id and inquiry.region_id not in region_map:
  1698. region = region_crud.get(db, inquiry.region_id)
  1699. if region:
  1700. region_map[inquiry.region_id] = region.name
  1701. return success_response({
  1702. "list": [{
  1703. "id": i.id,
  1704. "regionId": i.region_id,
  1705. "regionName": region_map.get(i.region_id),
  1706. "title": i.title,
  1707. "status": i.status,
  1708. "publishTime": str(i.publish_time) if i.publish_time else None,
  1709. "closeTime": str(i.close_time) if i.close_time else None,
  1710. "remark": i.remark,
  1711. "createAt": str(i.create_at) if i.create_at else None,
  1712. "createName": i.create_name
  1713. } for i in inquiries],
  1714. "total": total,
  1715. "page": page,
  1716. "pageSize": pageSize,
  1717. "totalPages": (total + pageSize - 1) // pageSize
  1718. })
  1719. @router.get("/inquiries/{inquiry_id}", response_model=dict)
  1720. def get_inquiry(
  1721. inquiry_id: str,
  1722. db: Session = Depends(get_db),
  1723. current_user: dict = Depends(require_admin)
  1724. ):
  1725. """获取询价单详情"""
  1726. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  1727. inquiry = inquiry_crud.get(db, inquiry_id)
  1728. if not inquiry:
  1729. return error_response(404, "询价单不存在")
  1730. items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
  1731. region_name = None
  1732. if inquiry.region_id:
  1733. region = region_crud.get(db, inquiry.region_id)
  1734. region_name = region.name if region else None
  1735. # 批量预取商品/分类/单位,避免明细循环内 N+1
  1736. product_ids = [
  1737. str(getattr(item, "product_id", "") or "")
  1738. for item in items
  1739. if getattr(item, "product_id", None)
  1740. ]
  1741. product_map, category_map, unit_map = _prefetch_products_categories_units(db, product_ids)
  1742. item_details = []
  1743. for item in items:
  1744. pid = str(getattr(item, "product_id", "") or "")
  1745. product = product_map.get(pid)
  1746. category_id = str(product.category_id) if product and product.category_id else None
  1747. unit_id = str(product.unit_id) if product and product.unit_id else None
  1748. item_details.append({
  1749. "id": item.id,
  1750. "productId": item.product_id,
  1751. "productName": product.name if product else None,
  1752. "categoryId": category_id,
  1753. "categoryName": category_map.get(category_id) if category_id else None,
  1754. "unitName": unit_map.get(unit_id) if unit_id else None,
  1755. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
  1756. "spec": getattr(product, "spec", None) if product else None,
  1757. "description": product.description if product else None,
  1758. "imageUrl": product.image_url if product else None,
  1759. "createAt": str(item.create_at) if item.create_at else None
  1760. })
  1761. return success_response({
  1762. "id": inquiry.id,
  1763. "regionId": inquiry.region_id,
  1764. "regionName": region_name,
  1765. "title": inquiry.title,
  1766. "status": inquiry.status,
  1767. "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None,
  1768. "closeTime": str(inquiry.close_time) if inquiry.close_time else None,
  1769. "remark": inquiry.remark,
  1770. "createAt": str(inquiry.create_at) if inquiry.create_at else None,
  1771. "createName": inquiry.create_name,
  1772. "items": item_details
  1773. })
  1774. @router.post("/inquiries", response_model=dict)
  1775. def create_inquiry(
  1776. request: dict,
  1777. db: Session = Depends(get_db),
  1778. current_user: dict = Depends(require_admin)
  1779. ):
  1780. """创建询价单"""
  1781. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  1782. if req_err is not None:
  1783. return req_err
  1784. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  1785. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  1786. inquiry_data = {
  1787. "region_id": request.get("regionId"),
  1788. "title": request.get("title"),
  1789. "remark": request.get("remark"),
  1790. "status": "draft"
  1791. }
  1792. inquiry = inquiry_crud.create(db, inquiry_data, creator)
  1793. product_ids = request.get("productIds", [])
  1794. if product_ids:
  1795. inquiry_item_crud.create_items(db, inquiry.id, product_ids, creator)
  1796. region_name = None
  1797. if inquiry.region_id:
  1798. region = region_crud.get(db, inquiry.region_id)
  1799. region_name = region.name if region else None
  1800. return success_response({
  1801. "id": inquiry.id,
  1802. "regionId": inquiry.region_id,
  1803. "regionName": region_name,
  1804. "title": inquiry.title,
  1805. "status": inquiry.status
  1806. })
  1807. @router.put("/inquiries/{inquiry_id}", response_model=dict)
  1808. def update_inquiry(
  1809. inquiry_id: str,
  1810. request: dict,
  1811. db: Session = Depends(get_db),
  1812. current_user: dict = Depends(require_admin)
  1813. ):
  1814. """更新询价单"""
  1815. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  1816. if req_err is not None:
  1817. return req_err
  1818. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  1819. inquiry = inquiry_crud.get(db, inquiry_id)
  1820. if not inquiry:
  1821. return error_response(404, "询价单不存在")
  1822. if inquiry.status != "draft":
  1823. return error_response(400, "只能编辑草稿状态的询价单")
  1824. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  1825. update_data = {}
  1826. if "title" in request:
  1827. update_data["title"] = request["title"]
  1828. if "remark" in request:
  1829. update_data["remark"] = request["remark"]
  1830. if update_data:
  1831. inquiry = inquiry_crud.update_with_history(db, inquiry_id, update_data, updater)
  1832. if "productIds" in request:
  1833. inquiry_item_crud.update_items(db, inquiry_id, request["productIds"], updater)
  1834. region_name = None
  1835. if inquiry.region_id:
  1836. region = region_crud.get(db, inquiry.region_id)
  1837. region_name = region.name if region else None
  1838. return success_response({
  1839. "id": inquiry.id,
  1840. "regionId": inquiry.region_id,
  1841. "regionName": region_name,
  1842. "title": inquiry.title,
  1843. "status": inquiry.status
  1844. })
  1845. @router.delete("/inquiries/{inquiry_id}", response_model=dict)
  1846. def delete_inquiry(
  1847. inquiry_id: str,
  1848. db: Session = Depends(get_db),
  1849. current_user: dict = Depends(require_admin)
  1850. ):
  1851. """删除询价单"""
  1852. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  1853. inquiry = inquiry_crud.get(db, inquiry_id)
  1854. if not inquiry:
  1855. return error_response(404, "询价单不存在")
  1856. if inquiry.status == "published":
  1857. return error_response(400, "已发布的询价单不能删除")
  1858. inquiry_item_crud.delete_by_inquiry(db, inquiry_id)
  1859. inquiry_crud.soft_delete_with_history(db, inquiry_id)
  1860. return success_response({"id": inquiry_id})
  1861. @router.post("/inquiries/{inquiry_id}/publish", response_model=dict)
  1862. def publish_inquiry(
  1863. inquiry_id: str,
  1864. db: Session = Depends(get_db),
  1865. current_user: dict = Depends(require_admin)
  1866. ):
  1867. """发布询价单"""
  1868. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  1869. from app.models.models import BizInquiry
  1870. from sqlalchemy import and_
  1871. inquiry = inquiry_crud.get(db, inquiry_id)
  1872. if not inquiry:
  1873. return error_response(404, "询价单不存在")
  1874. if inquiry.status != "draft":
  1875. return error_response(400, "只能发布草稿状态的询价单")
  1876. items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
  1877. if not items:
  1878. return error_response(400, "询价单必须包含至少一个商品")
  1879. # 同区域仅允许一个发布中的询价单(并发下尽量避免双发布)
  1880. try:
  1881. # 锁住同区域发布中的记录(若存在)与当前记录
  1882. db.query(BizInquiry).filter(
  1883. and_(
  1884. BizInquiry.region_id == inquiry.region_id,
  1885. BizInquiry.status == "published",
  1886. BizInquiry.is_del == 0
  1887. )
  1888. ).with_for_update().all()
  1889. db.query(BizInquiry).filter(BizInquiry.id == inquiry_id).with_for_update().all()
  1890. except Exception:
  1891. # with_for_update 在某些配置下可能不生效,但不影响基本校验
  1892. pass
  1893. existing = inquiry_crud.get_current_published_by_region(db, inquiry.region_id)
  1894. if existing and existing.id != inquiry_id:
  1895. return error_response(400, "该区域已存在发布中的询价单,请先取消发布当前询价单后再发布新的询价单")
  1896. inquiry = inquiry_crud.publish(db, inquiry_id)
  1897. return success_response({
  1898. "id": inquiry.id,
  1899. "status": inquiry.status,
  1900. "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None
  1901. })
  1902. @router.post("/inquiries/{inquiry_id}/unpublish", response_model=dict)
  1903. def unpublish_inquiry(
  1904. inquiry_id: str,
  1905. db: Session = Depends(get_db),
  1906. current_user: dict = Depends(require_admin)
  1907. ):
  1908. """取消发布询价单(published -> draft)"""
  1909. from app.crud.inquiry import inquiry_crud
  1910. inquiry = inquiry_crud.get(db, inquiry_id)
  1911. if not inquiry:
  1912. return error_response(404, "询价单不存在")
  1913. if inquiry.status != "published":
  1914. return error_response(400, "只能取消发布已发布状态的询价单")
  1915. inquiry = inquiry_crud.unpublish(db, inquiry_id)
  1916. return success_response({
  1917. "id": inquiry.id,
  1918. "status": inquiry.status
  1919. })
  1920. @router.post("/inquiries/{inquiry_id}/close", response_model=dict)
  1921. def close_inquiry(
  1922. inquiry_id: str,
  1923. db: Session = Depends(get_db),
  1924. current_user: dict = Depends(require_admin)
  1925. ):
  1926. """关闭询价单"""
  1927. from app.crud.inquiry import inquiry_crud
  1928. inquiry = inquiry_crud.get(db, inquiry_id)
  1929. if not inquiry:
  1930. return error_response(404, "询价单不存在")
  1931. if inquiry.status != "published":
  1932. return error_response(400, "只能关闭已发布的询价单")
  1933. inquiry = inquiry_crud.close(db, inquiry_id)
  1934. return success_response({
  1935. "id": inquiry.id,
  1936. "status": inquiry.status,
  1937. "closeTime": str(inquiry.close_time) if inquiry.close_time else None
  1938. })
  1939. # ============ 报价管理(无审批) ============
  1940. @router.get("/quotations", response_model=dict)
  1941. def list_quotations(
  1942. page: int = Query(1, ge=1, description="页码"),
  1943. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  1944. inquiryId: Optional[str] = Query(None, description="询价单ID"),
  1945. regionId: Optional[str] = Query(None, description="区域ID"),
  1946. status: Optional[str] = Query(None, description="状态: submitted/selected"),
  1947. db: Session = Depends(get_db),
  1948. current_user: dict = Depends(require_admin)
  1949. ):
  1950. """获取报价单列表"""
  1951. from app.crud.quotation import quotation_crud, quotation_item_crud
  1952. from app.crud.inquiry import inquiry_crud
  1953. from app.crud.user import user_crud
  1954. # 区域管理员只能查看自己区域的报价单
  1955. admin_region_id = get_admin_region_filter(db, current_user)
  1956. if admin_region_id and not regionId:
  1957. regionId = admin_region_id
  1958. elif admin_region_id and regionId != admin_region_id:
  1959. return success_response({
  1960. "list": [],
  1961. "total": 0,
  1962. "page": page,
  1963. "pageSize": pageSize,
  1964. "totalPages": 0
  1965. })
  1966. quotations, total = quotation_crud.get_multi_with_filters(
  1967. db,
  1968. inquiry_id=inquiryId,
  1969. region_id=regionId,
  1970. status=status,
  1971. skip=(page - 1) * pageSize,
  1972. limit=pageSize
  1973. )
  1974. region_map = {}
  1975. inquiry_map = {}
  1976. supplier_map = {}
  1977. for q in quotations:
  1978. if q.region_id and q.region_id not in region_map:
  1979. region = region_crud.get(db, q.region_id)
  1980. region_map[q.region_id] = region.name if region else None
  1981. if q.inquiry_id and q.inquiry_id not in inquiry_map:
  1982. inquiry = inquiry_crud.get(db, q.inquiry_id)
  1983. inquiry_map[q.inquiry_id] = inquiry.title if inquiry else None
  1984. if q.supplier_id and q.supplier_id not in supplier_map:
  1985. supplier = user_crud.get(db, q.supplier_id)
  1986. supplier_map[q.supplier_id] = supplier.real_name if supplier else None
  1987. return success_response({
  1988. "list": [{
  1989. "id": q.id,
  1990. "inquiryId": q.inquiry_id,
  1991. "inquiryTitle": inquiry_map.get(q.inquiry_id),
  1992. "supplierId": q.supplier_id,
  1993. "supplierName": supplier_map.get(q.supplier_id),
  1994. "regionId": q.region_id,
  1995. "regionName": region_map.get(q.region_id),
  1996. "status": q.status,
  1997. "totalAmount": float(q.total_amount) if q.total_amount else 0,
  1998. "submitTime": str(q.submit_time) if q.submit_time else None,
  1999. "createAt": str(q.create_at) if q.create_at else None
  2000. } for q in quotations],
  2001. "total": total,
  2002. "page": page,
  2003. "pageSize": pageSize,
  2004. "totalPages": (total + pageSize - 1) // pageSize
  2005. })
  2006. @router.get("/quotations/{quotation_id}", response_model=dict)
  2007. def get_quotation(
  2008. quotation_id: str,
  2009. db: Session = Depends(get_db),
  2010. current_user: dict = Depends(require_admin)
  2011. ):
  2012. """获取报价单详情"""
  2013. from app.crud.quotation import quotation_crud, quotation_item_crud
  2014. from app.crud.inquiry import inquiry_crud
  2015. from app.crud.user import user_crud
  2016. from app.models.models import BizPriceList, BizPriceListItem, BizInquiryItem
  2017. from sqlalchemy import and_
  2018. quotation = quotation_crud.get(db, quotation_id)
  2019. if not quotation:
  2020. return error_response(404, "报价单不存在")
  2021. # 询价单商品作为主表,确保供应商未报价商品也能展示
  2022. inquiry_items = db.query(BizInquiryItem).filter(
  2023. and_(
  2024. BizInquiryItem.inquiry_id == quotation.inquiry_id,
  2025. BizInquiryItem.is_del == 0
  2026. )
  2027. ).order_by(BizInquiryItem.create_at.asc()).all()
  2028. # 报价单项作为从表(按 product_id 映射)
  2029. items = quotation_item_crud.get_by_quotation(db, quotation_id)
  2030. q_item_by_pid: dict[str, object] = {}
  2031. for it in items:
  2032. pid = str(getattr(it, "product_id", "") or "")
  2033. if pid and pid not in q_item_by_pid:
  2034. q_item_by_pid[pid] = it
  2035. # 若该报价已被选中并生成价格清单,则回显调价系数/最终售价(用于运营端查看)
  2036. pl = db.query(BizPriceList).filter(
  2037. and_(
  2038. BizPriceList.quotation_id == quotation_id,
  2039. BizPriceList.is_del == 0
  2040. )
  2041. ).order_by(BizPriceList.version.desc(), BizPriceList.published_time.desc()).first()
  2042. # key: product_id, value: pricing_coefficient(%) used at publish-time
  2043. # NOTE: older price lists may have NULL pricing_coefficient; we keep None to allow fallback to product.price_coefficient.
  2044. coeff_map: dict[str, Optional[float]] = {}
  2045. final_price_map: dict[str, float] = {}
  2046. if pl:
  2047. pl_items = db.query(BizPriceListItem).filter(
  2048. and_(
  2049. BizPriceListItem.price_list_id == pl.id,
  2050. BizPriceListItem.is_del == 0
  2051. )
  2052. ).all()
  2053. for pit in pl_items:
  2054. pid = str(pit.product_id or "")
  2055. if not pid:
  2056. continue
  2057. coeff_map[pid] = float(pit.pricing_coefficient) if getattr(pit, "pricing_coefficient", None) is not None else None
  2058. final_price_map[pid] = float(pit.price) if pit.price else 0
  2059. region_name = None
  2060. if quotation.region_id:
  2061. region = region_crud.get(db, quotation.region_id)
  2062. region_name = region.name if region else None
  2063. inquiry_title = None
  2064. if quotation.inquiry_id:
  2065. inquiry = inquiry_crud.get(db, quotation.inquiry_id)
  2066. inquiry_title = inquiry.title if inquiry else None
  2067. supplier_name = None
  2068. supplier_phone = None
  2069. if quotation.supplier_id:
  2070. supplier = user_crud.get(db, quotation.supplier_id)
  2071. if supplier:
  2072. supplier_name = supplier.real_name
  2073. supplier_phone = supplier.phone
  2074. product_ids = [
  2075. str(getattr(inq_item, "product_id", "") or "")
  2076. for inq_item in inquiry_items
  2077. if getattr(inq_item, "product_id", None)
  2078. ]
  2079. extra_unit_ids = [
  2080. str(getattr(q_item, "unit_id", "") or "")
  2081. for q_item in q_item_by_pid.values()
  2082. if getattr(q_item, "unit_id", None)
  2083. ]
  2084. product_map, category_map, unit_map = _prefetch_products_categories_units(
  2085. db, product_ids, extra_unit_ids
  2086. )
  2087. item_details = []
  2088. for inq_item in inquiry_items:
  2089. pid = str(getattr(inq_item, "product_id", "") or "")
  2090. q_item = q_item_by_pid.get(pid)
  2091. product = product_map.get(pid)
  2092. default_coeff = _numeric_to_float_optional(getattr(product, "price_coefficient", None)) if product else None
  2093. default_coeff = float(default_coeff) if default_coeff is not None else 0.0
  2094. category_id = str(product.category_id) if product and getattr(product, "category_id", None) else None
  2095. category_name = category_map.get(category_id) if category_id else None
  2096. unit_id = None
  2097. # unit 优先取报价单项(更准确),否则回退商品默认 unit_id
  2098. if q_item and getattr(q_item, "unit_id", None):
  2099. unit_id = getattr(q_item, "unit_id", None)
  2100. elif product and getattr(product, "unit_id", None):
  2101. unit_id = getattr(product, "unit_id", None)
  2102. unit_name = unit_map.get(str(unit_id)) if unit_id else None
  2103. supplier_price = float(getattr(q_item, "price", 0) or 0) if q_item else 0
  2104. pricing_coeff = coeff_map.get(pid, None)
  2105. if pricing_coeff is None:
  2106. pricing_coeff = default_coeff
  2107. item_details.append({
  2108. "id": getattr(q_item, "id", None) if q_item else None,
  2109. "productId": pid,
  2110. "productName": product.name if product else None,
  2111. "imageUrl": getattr(product, "image_url", None) if product else None,
  2112. "categoryName": category_name,
  2113. "unitId": unit_id,
  2114. "unitName": unit_name,
  2115. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
  2116. "spec": getattr(product, "spec", None) if product else None,
  2117. "supplierPrice": supplier_price,
  2118. "pricingCoefficient": pricing_coeff,
  2119. "finalPrice": final_price_map.get(pid, 0),
  2120. # 兼容旧前端字段:price 仍返回供应商报价
  2121. "price": supplier_price
  2122. })
  2123. return success_response({
  2124. "id": quotation.id,
  2125. "inquiryId": quotation.inquiry_id,
  2126. "inquiryTitle": inquiry_title,
  2127. "supplierId": quotation.supplier_id,
  2128. "supplierName": supplier_name,
  2129. "supplierPhone": supplier_phone,
  2130. "regionId": quotation.region_id,
  2131. "regionName": region_name,
  2132. "status": quotation.status,
  2133. "totalAmount": float(quotation.total_amount) if quotation.total_amount else 0,
  2134. "submitTime": str(quotation.submit_time) if quotation.submit_time else None,
  2135. "createAt": str(quotation.create_at) if quotation.create_at else None,
  2136. "items": item_details
  2137. })
  2138. # =========================================
  2139. # 询价单报价聚合查询(用于询价报价管理页)
  2140. # =========================================
  2141. @router.get("/inquiries/{inquiry_id}/quotations/latest-by-supplier", response_model=dict)
  2142. def list_latest_quotations_by_supplier(
  2143. inquiry_id: str,
  2144. db: Session = Depends(get_db),
  2145. current_user: dict = Depends(require_admin)
  2146. ):
  2147. """按询价单聚合:每个供应商最新一次报价(包含未报价供应商)"""
  2148. from app.models.models import (
  2149. BizInquiry,
  2150. BizQuotation,
  2151. SysUser,
  2152. BizInquiryItem,
  2153. BizQuotationItem,
  2154. BasProduct,
  2155. BasProductCategory,
  2156. BasProductUnit,
  2157. )
  2158. from sqlalchemy import and_
  2159. inquiry = db.query(BizInquiry).filter(and_(BizInquiry.id == inquiry_id, BizInquiry.is_del == 0)).first()
  2160. if not inquiry:
  2161. return error_response(404, "询价单不存在")
  2162. # 询价单商品作为主表:后续给每个供应商补齐这些商品行
  2163. inquiry_items = db.query(BizInquiryItem).filter(
  2164. and_(
  2165. BizInquiryItem.inquiry_id == inquiry_id,
  2166. BizInquiryItem.is_del == 0
  2167. )
  2168. ).order_by(BizInquiryItem.create_at.asc()).all()
  2169. # 该区域全部供应商(包含未报价)
  2170. suppliers = db.query(SysUser).filter(
  2171. and_(
  2172. SysUser.role == "supplier",
  2173. SysUser.region_id == inquiry.region_id,
  2174. SysUser.is_del == 0,
  2175. SysUser.is_active == True
  2176. )
  2177. ).order_by(SysUser.create_at.asc()).all()
  2178. # 查询该询价单下全部报价单,然后在内存中取每个供应商最新一条
  2179. quotations = db.query(BizQuotation).filter(
  2180. and_(
  2181. BizQuotation.inquiry_id == inquiry_id,
  2182. BizQuotation.is_del == 0
  2183. )
  2184. ).order_by(BizQuotation.create_at.desc()).all()
  2185. latest_by_supplier: dict[str, BizQuotation] = {}
  2186. for q in quotations:
  2187. if q.supplier_id not in latest_by_supplier:
  2188. latest_by_supplier[q.supplier_id] = q
  2189. # 询价商品维度预取(避免循环内 N+1)
  2190. inquiry_product_ids = [str(getattr(x, "product_id", "") or "") for x in inquiry_items if getattr(x, "product_id", None)]
  2191. inquiry_product_ids = [x for x in inquiry_product_ids if x]
  2192. products = []
  2193. if inquiry_product_ids:
  2194. products = db.query(BasProduct).filter(
  2195. and_(BasProduct.id.in_(inquiry_product_ids), BasProduct.is_del == 0)
  2196. ).all()
  2197. product_map: dict[str, object] = {str(getattr(p, "id", "")): p for p in products}
  2198. category_ids = list({
  2199. str(getattr(p, "category_id", "") or "")
  2200. for p in products if getattr(p, "category_id", None)
  2201. })
  2202. categories = []
  2203. if category_ids:
  2204. categories = db.query(BasProductCategory).filter(
  2205. and_(BasProductCategory.id.in_(category_ids), BasProductCategory.is_del == 0)
  2206. ).all()
  2207. category_map: dict[str, str] = {
  2208. str(getattr(c, "id", "")): str(getattr(c, "name", "") or "")
  2209. for c in categories
  2210. }
  2211. # 最新报价单项批量预取(按 quotation_id + product_id 建映射)
  2212. latest_qids = [str(getattr(q, "id", "") or "") for q in latest_by_supplier.values() if getattr(q, "id", None)]
  2213. quotation_items_by_qid: dict[str, dict[str, object]] = {}
  2214. unit_ids = set(
  2215. str(getattr(p, "unit_id", "") or "")
  2216. for p in products if getattr(p, "unit_id", None)
  2217. )
  2218. if latest_qids:
  2219. q_items = db.query(BizQuotationItem).filter(
  2220. and_(BizQuotationItem.quotation_id.in_(latest_qids), BizQuotationItem.is_del == 0)
  2221. ).all()
  2222. for it in q_items:
  2223. qid = str(getattr(it, "quotation_id", "") or "")
  2224. pid = str(getattr(it, "product_id", "") or "")
  2225. if not qid or not pid:
  2226. continue
  2227. quotation_items_by_qid.setdefault(qid, {})
  2228. if pid not in quotation_items_by_qid[qid]:
  2229. quotation_items_by_qid[qid][pid] = it
  2230. if getattr(it, "unit_id", None):
  2231. unit_ids.add(str(getattr(it, "unit_id", "") or ""))
  2232. units = []
  2233. clean_unit_ids = [x for x in unit_ids if x]
  2234. if clean_unit_ids:
  2235. units = db.query(BasProductUnit).filter(
  2236. and_(BasProductUnit.id.in_(clean_unit_ids), BasProductUnit.is_del == 0)
  2237. ).all()
  2238. unit_map: dict[str, str] = {
  2239. str(getattr(u, "id", "")): str(getattr(u, "name", "") or "")
  2240. for u in units
  2241. }
  2242. # 组装返回:供应商维度 + 最新报价(如无则为空)
  2243. result = []
  2244. for s in suppliers:
  2245. q = latest_by_supplier.get(s.id)
  2246. quotation_items = []
  2247. q_item_by_pid: dict[str, object] = {}
  2248. if q and getattr(q, "id", None):
  2249. q_item_by_pid = quotation_items_by_qid.get(str(q.id), {})
  2250. # LEFT JOIN:以询价单商品为主,补齐未报价商品
  2251. for inq_item in inquiry_items:
  2252. pid = str(getattr(inq_item, "product_id", "") or "")
  2253. it = q_item_by_pid.get(pid)
  2254. product = product_map.get(pid)
  2255. category_name = category_map.get(str(getattr(product, "category_id", "") or ""), None) if product else None
  2256. unit_id = None
  2257. if it and getattr(it, "unit_id", None):
  2258. unit_id = getattr(it, "unit_id", None)
  2259. elif product and getattr(product, "unit_id", None):
  2260. unit_id = getattr(product, "unit_id", None)
  2261. unit_name = unit_map.get(str(unit_id or ""), None) if unit_id else None
  2262. price_val = None
  2263. if it is not None:
  2264. try:
  2265. price_val = float(getattr(it, "price", 0) or 0)
  2266. except Exception:
  2267. price_val = 0
  2268. quotation_items.append({
  2269. "id": getattr(it, "id", None) if it else None,
  2270. "productId": pid,
  2271. "productName": product.name if product else None,
  2272. "categoryName": category_name,
  2273. "unitId": unit_id,
  2274. "unitName": unit_name,
  2275. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
  2276. "spec": getattr(product, "spec", None) if product else None,
  2277. "imageUrl": getattr(product, "image_url", None) if product else None,
  2278. # 没报价时保持 null,便于前端区分“未报价”与“0元”
  2279. "price": price_val
  2280. })
  2281. result.append({
  2282. "supplierId": s.id,
  2283. "supplierName": s.real_name or s.username,
  2284. "supplierPhone": s.phone,
  2285. # 即使供应商未报价,也返回 quotation.items(=询价单全部商品),用于前端完整展示商品行
  2286. "quotation": {
  2287. "id": q.id if q else None,
  2288. "status": q.status if q else None,
  2289. "totalAmount": float(q.total_amount) if (q and q.total_amount) else 0,
  2290. "submitTime": str(q.submit_time) if (q and q.submit_time) else None,
  2291. "createAt": str(q.create_at) if (q and q.create_at) else None,
  2292. "items": quotation_items
  2293. }
  2294. })
  2295. return success_response({
  2296. "inquiryId": inquiry_id,
  2297. "regionId": inquiry.region_id,
  2298. "list": result,
  2299. "total": len(result)
  2300. })
  2301. @router.get("/inquiries/{inquiry_id}/quotations/history", response_model=dict)
  2302. def list_quotation_history_by_inquiry(
  2303. inquiry_id: str,
  2304. page: int = Query(1, ge=1, description="页码"),
  2305. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  2306. supplierId: Optional[str] = Query(None, description="供应商ID"),
  2307. status: Optional[str] = Query(None, description="状态: submitted/selected"),
  2308. startDate: Optional[str] = Query(None, description="开始日期(ISO)"),
  2309. endDate: Optional[str] = Query(None, description="结束日期(ISO)"),
  2310. db: Session = Depends(get_db),
  2311. current_user: dict = Depends(require_admin)
  2312. ):
  2313. """按询价单查看全部报价单(第三个tab)"""
  2314. from app.models.models import BizQuotation, BizInquiry, BizQuotationItem, BizInquiryItem
  2315. from app.crud.quotation import quotation_crud
  2316. from app.crud.user import user_crud
  2317. from sqlalchemy import and_
  2318. from datetime import datetime
  2319. inquiry = db.query(BizInquiry).filter(and_(BizInquiry.id == inquiry_id, BizInquiry.is_del == 0)).first()
  2320. if not inquiry:
  2321. return error_response(404, "询价单不存在")
  2322. query = db.query(BizQuotation).filter(
  2323. and_(
  2324. BizQuotation.is_del == 0,
  2325. BizQuotation.inquiry_id == inquiry_id,
  2326. BizQuotation.region_id == inquiry.region_id
  2327. )
  2328. )
  2329. if supplierId:
  2330. query = query.filter(BizQuotation.supplier_id == supplierId)
  2331. if status:
  2332. query = query.filter(BizQuotation.status == status)
  2333. def _parse_dt(v: Optional[str]) -> Optional[datetime]:
  2334. if not v:
  2335. return None
  2336. try:
  2337. return datetime.fromisoformat(v.replace("Z", "+00:00"))
  2338. except Exception:
  2339. return None
  2340. sd = _parse_dt(startDate)
  2341. ed = _parse_dt(endDate)
  2342. if sd:
  2343. query = query.filter(BizQuotation.create_at >= sd)
  2344. if ed:
  2345. query = query.filter(BizQuotation.create_at <= ed)
  2346. total = query.count()
  2347. quotations = query.order_by(BizQuotation.create_at.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
  2348. supplier_map = {}
  2349. for q in quotations:
  2350. if q.supplier_id and q.supplier_id not in supplier_map:
  2351. u = user_crud.get(db, q.supplier_id)
  2352. supplier_map[q.supplier_id] = u.real_name if u else None
  2353. q_ids = [q.id for q in quotations]
  2354. partial_ids: set[str] = set()
  2355. inquiry_pids = [
  2356. r[0]
  2357. for r in db.query(BizInquiryItem.product_id)
  2358. .filter(BizInquiryItem.inquiry_id == inquiry_id, BizInquiryItem.is_del == 0)
  2359. .all()
  2360. ]
  2361. expected = set(inquiry_pids)
  2362. if q_ids:
  2363. item_rows = (
  2364. db.query(BizQuotationItem.quotation_id, BizQuotationItem.product_id, BizQuotationItem.price)
  2365. .filter(BizQuotationItem.quotation_id.in_(q_ids), BizQuotationItem.is_del == 0)
  2366. .all()
  2367. )
  2368. by_quotation: dict[str, dict[str, object]] = {}
  2369. for qid, pid, price in item_rows:
  2370. by_quotation.setdefault(qid, {})[pid] = price
  2371. for qid in q_ids:
  2372. pmap = by_quotation.get(qid, {})
  2373. if expected:
  2374. for pid in expected:
  2375. pr = pmap.get(pid)
  2376. try:
  2377. pv = float(pr) if pr is not None else 0.0
  2378. except (TypeError, ValueError):
  2379. pv = 0.0
  2380. if pv <= 0:
  2381. partial_ids.add(qid)
  2382. break
  2383. else:
  2384. for pr in pmap.values():
  2385. try:
  2386. pv = float(pr) if pr is not None else 0.0
  2387. except (TypeError, ValueError):
  2388. pv = 0.0
  2389. if pv <= 0:
  2390. partial_ids.add(qid)
  2391. break
  2392. return success_response({
  2393. "list": [{
  2394. "id": q.id,
  2395. "inquiryId": q.inquiry_id,
  2396. "supplierId": q.supplier_id,
  2397. "supplierName": supplier_map.get(q.supplier_id),
  2398. "regionId": q.region_id,
  2399. "status": q.status,
  2400. "totalAmount": float(q.total_amount) if q.total_amount else 0,
  2401. "submitTime": str(q.submit_time) if q.submit_time else None,
  2402. "createAt": str(q.create_at) if q.create_at else None,
  2403. "partialPriceMissing": q.id in partial_ids
  2404. } for q in quotations],
  2405. "total": total,
  2406. "page": page,
  2407. "pageSize": pageSize,
  2408. "totalPages": (total + pageSize - 1) // pageSize
  2409. })
  2410. @router.get("/inquiries/{inquiry_id}/quotations/selected", response_model=dict)
  2411. def get_selected_quotation_for_inquiry(
  2412. inquiry_id: str,
  2413. db: Session = Depends(get_db),
  2414. current_user: dict = Depends(require_admin)
  2415. ):
  2416. """获取某询价单当前被选中的报价(若已生成价格清单,则从价格清单反查)"""
  2417. from app.models.models import (
  2418. BizInquiry,
  2419. BizQuotation,
  2420. BizPriceList,
  2421. BizPriceListItem,
  2422. BizQuotationItem,
  2423. BizInquiryItem,
  2424. BasProduct,
  2425. BasProductCategory,
  2426. BasProductUnit,
  2427. )
  2428. from sqlalchemy import and_
  2429. from app.crud.user import user_crud
  2430. inquiry = db.query(BizInquiry).filter(and_(BizInquiry.id == inquiry_id, BizInquiry.is_del == 0)).first()
  2431. if not inquiry:
  2432. return error_response(404, "询价单不存在")
  2433. # 询价单商品清单(作为主表,确保完整展示)
  2434. inquiry_items = db.query(BizInquiryItem).filter(
  2435. and_(
  2436. BizInquiryItem.inquiry_id == inquiry_id,
  2437. BizInquiryItem.is_del == 0
  2438. )
  2439. ).order_by(BizInquiryItem.create_at.asc()).all()
  2440. inquiry_product_ids = [str(getattr(x, "product_id", "") or "") for x in inquiry_items if getattr(x, "product_id", None)]
  2441. inquiry_product_ids = [x for x in inquiry_product_ids if x]
  2442. products = []
  2443. if inquiry_product_ids:
  2444. products = db.query(BasProduct).filter(
  2445. and_(BasProduct.id.in_(inquiry_product_ids), BasProduct.is_del == 0)
  2446. ).all()
  2447. product_map: dict[str, object] = {str(getattr(p, "id", "")): p for p in products}
  2448. category_ids = list({
  2449. str(getattr(p, "category_id", "") or "")
  2450. for p in products if getattr(p, "category_id", None)
  2451. })
  2452. categories = []
  2453. if category_ids:
  2454. categories = db.query(BasProductCategory).filter(
  2455. and_(BasProductCategory.id.in_(category_ids), BasProductCategory.is_del == 0)
  2456. ).all()
  2457. category_map: dict[str, str] = {
  2458. str(getattr(c, "id", "")): str(getattr(c, "name", "") or "")
  2459. for c in categories
  2460. }
  2461. unit_ids_early = {
  2462. str(getattr(p, "unit_id", "") or "")
  2463. for p in products if getattr(p, "unit_id", None)
  2464. }
  2465. units_early = []
  2466. clean_unit_ids_early = [x for x in unit_ids_early if x]
  2467. if clean_unit_ids_early:
  2468. units_early = db.query(BasProductUnit).filter(
  2469. and_(BasProductUnit.id.in_(clean_unit_ids_early), BasProductUnit.is_del == 0)
  2470. ).all()
  2471. unit_map_early: dict[str, str] = {
  2472. str(getattr(u, "id", "")): str(getattr(u, "name", "") or "")
  2473. for u in units_early
  2474. }
  2475. # 优先从价格清单查来源(允许后续价格已被新的询价覆盖 active)
  2476. pl = db.query(BizPriceList).filter(
  2477. and_(
  2478. BizPriceList.inquiry_id == inquiry_id,
  2479. BizPriceList.is_del == 0
  2480. )
  2481. ).order_by(BizPriceList.version.desc(), BizPriceList.published_time.desc()).first()
  2482. if not pl:
  2483. # 兜底:直接查 selected 报价
  2484. q = db.query(BizQuotation).filter(
  2485. and_(
  2486. BizQuotation.inquiry_id == inquiry_id,
  2487. BizQuotation.status == "selected",
  2488. BizQuotation.is_del == 0
  2489. )
  2490. ).order_by(BizQuotation.create_at.desc()).first()
  2491. if not q:
  2492. # 未选择任何供应商报价时,也返回询价单商品清单(用于前端完整展示)
  2493. item_details = []
  2494. for inq_item in inquiry_items:
  2495. pid = str(getattr(inq_item, "product_id", "") or "")
  2496. product = product_map.get(pid)
  2497. default_coeff = _numeric_to_float_optional(getattr(product, "price_coefficient", None)) if product else None
  2498. default_coeff = float(default_coeff) if default_coeff is not None else 0.0
  2499. category_name = category_map.get(str(getattr(product, "category_id", "") or ""), None) if product else None
  2500. unit_id = getattr(product, "unit_id", None) if product else None
  2501. unit_name = unit_map_early.get(str(unit_id or ""), None) if unit_id else None
  2502. item_details.append({
  2503. "productId": pid,
  2504. "productName": product.name if product else None,
  2505. "skuCode": getattr(product, "sku_code", None) if product else None,
  2506. "description": getattr(product, "description", None) if product else None,
  2507. "imageUrl": getattr(product, "image_url", None) if product else None,
  2508. "categoryName": category_name,
  2509. "unitId": unit_id,
  2510. "unitName": unit_name,
  2511. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
  2512. "spec": getattr(product, "spec", None) if product else None,
  2513. # 未报价:返回 0 / 0,前端可按需展示为 '-'
  2514. "supplierPrice": 0,
  2515. "price": 0,
  2516. "pricingCoefficient": default_coeff
  2517. })
  2518. return success_response({
  2519. "quotationId": None,
  2520. "quotationCreateAt": None,
  2521. "quotationSubmitTime": None,
  2522. "supplierId": None,
  2523. "supplierName": None,
  2524. "supplierLogoUrl": None,
  2525. "items": item_details
  2526. })
  2527. supplier = user_crud.get(db, q.supplier_id) if q.supplier_id else None
  2528. # 兜底分支也补齐询价单商品行:supplierPrice 从报价项拿,最终售价未知则为 0
  2529. supplier_price_map: dict[str, float] = {}
  2530. q_items = db.query(BizQuotationItem).filter(
  2531. and_(BizQuotationItem.quotation_id == q.id, BizQuotationItem.is_del == 0)
  2532. ).all()
  2533. for qi in q_items:
  2534. if not qi.product_id:
  2535. continue
  2536. supplier_price_map[str(qi.product_id)] = float(qi.price) if qi.price else 0
  2537. item_details = []
  2538. for inq_item in inquiry_items:
  2539. pid = str(getattr(inq_item, "product_id", "") or "")
  2540. product = product_map.get(pid)
  2541. default_coeff = _numeric_to_float_optional(getattr(product, "price_coefficient", None)) if product else None
  2542. default_coeff = float(default_coeff) if default_coeff is not None else 0.0
  2543. category_name = category_map.get(str(getattr(product, "category_id", "") or ""), None) if product else None
  2544. unit_id = getattr(product, "unit_id", None) if product else None
  2545. unit_name = unit_map_early.get(str(unit_id or ""), None) if unit_id else None
  2546. item_details.append({
  2547. "productId": pid,
  2548. "productName": product.name if product else None,
  2549. "skuCode": getattr(product, "sku_code", None) if product else None,
  2550. "description": getattr(product, "description", None) if product else None,
  2551. "imageUrl": getattr(product, "image_url", None) if product else None,
  2552. "categoryName": category_name,
  2553. "unitId": unit_id,
  2554. "unitName": unit_name,
  2555. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
  2556. "spec": getattr(product, "spec", None) if product else None,
  2557. # 未报价商品返回 0(兜底分支无法区分 null/0;主流程会更准确)
  2558. "supplierPrice": supplier_price_map.get(pid, 0),
  2559. "price": 0,
  2560. "pricingCoefficient": default_coeff
  2561. })
  2562. return success_response({
  2563. "quotationId": q.id,
  2564. "quotationCreateAt": str(q.create_at) if getattr(q, "create_at", None) else None,
  2565. "quotationSubmitTime": str(q.submit_time) if getattr(q, "submit_time", None) else None,
  2566. "supplierId": q.supplier_id,
  2567. "supplierName": supplier.real_name if supplier else None,
  2568. "supplierLogoUrl": getattr(supplier, "avatar_url", None) if supplier else None,
  2569. "items": item_details
  2570. })
  2571. supplier = user_crud.get(db, pl.supplier_id) if pl.supplier_id else None
  2572. quotation_create_at = None
  2573. quotation_submit_time = None
  2574. if pl.quotation_id:
  2575. q = db.query(BizQuotation).filter(
  2576. and_(
  2577. BizQuotation.id == pl.quotation_id,
  2578. BizQuotation.is_del == 0
  2579. )
  2580. ).first()
  2581. if q:
  2582. quotation_create_at = str(q.create_at) if getattr(q, "create_at", None) else None
  2583. quotation_submit_time = str(q.submit_time) if getattr(q, "submit_time", None) else None
  2584. items = db.query(BizPriceListItem).filter(
  2585. and_(BizPriceListItem.price_list_id == pl.id, BizPriceListItem.is_del == 0)
  2586. ).all()
  2587. # price list item 按 product_id 映射(LEFT JOIN)
  2588. pl_item_by_pid: dict[str, object] = {}
  2589. for it in items:
  2590. pid = str(getattr(it, "product_id", "") or "")
  2591. if pid and pid not in pl_item_by_pid:
  2592. pl_item_by_pid[pid] = it
  2593. supplier_price_map: dict[str, float] = {}
  2594. if pl.quotation_id:
  2595. q_items = db.query(BizQuotationItem).filter(
  2596. and_(BizQuotationItem.quotation_id == pl.quotation_id, BizQuotationItem.is_del == 0)
  2597. ).all()
  2598. for qi in q_items:
  2599. if not qi.product_id:
  2600. continue
  2601. supplier_price_map[str(qi.product_id)] = float(qi.price) if qi.price else 0
  2602. unit_ids = {
  2603. str(getattr(p, "unit_id", "") or "")
  2604. for p in products if getattr(p, "unit_id", None)
  2605. }
  2606. for pit in items:
  2607. if getattr(pit, "unit_id", None):
  2608. unit_ids.add(str(getattr(pit, "unit_id", "") or ""))
  2609. units = []
  2610. clean_unit_ids = [x for x in unit_ids if x]
  2611. if clean_unit_ids:
  2612. units = db.query(BasProductUnit).filter(
  2613. and_(BasProductUnit.id.in_(clean_unit_ids), BasProductUnit.is_del == 0)
  2614. ).all()
  2615. unit_map: dict[str, str] = {
  2616. str(getattr(u, "id", "")): str(getattr(u, "name", "") or "")
  2617. for u in units
  2618. }
  2619. item_details = []
  2620. for inq_item in inquiry_items:
  2621. pid = str(getattr(inq_item, "product_id", "") or "")
  2622. it = pl_item_by_pid.get(pid)
  2623. product = product_map.get(pid)
  2624. default_coeff = _numeric_to_float_optional(getattr(product, "price_coefficient", None)) if product else None
  2625. default_coeff = float(default_coeff) if default_coeff is not None else 0.0
  2626. category_name = category_map.get(str(getattr(product, "category_id", "") or ""), None) if product else None
  2627. unit_id = None
  2628. if it and getattr(it, "unit_id", None):
  2629. unit_id = getattr(it, "unit_id", None)
  2630. elif product and getattr(product, "unit_id", None):
  2631. unit_id = getattr(product, "unit_id", None)
  2632. unit_name = unit_map.get(str(unit_id or ""), None) if unit_id else None
  2633. item_details.append({
  2634. "productId": pid,
  2635. "productName": product.name if product else None,
  2636. "skuCode": getattr(product, "sku_code", None) if product else None,
  2637. "description": getattr(product, "description", None) if product else None,
  2638. "imageUrl": getattr(product, "image_url", None) if product else None,
  2639. "categoryName": category_name,
  2640. "unitId": unit_id,
  2641. "unitName": unit_name,
  2642. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
  2643. "spec": getattr(product, "spec", None) if product else None,
  2644. "supplierPrice": supplier_price_map.get(pid, 0),
  2645. # 最终售价来自 price list item;未生成/不存在时返回 0
  2646. "price": float(getattr(it, "price", 0) or 0) if it else 0,
  2647. "pricingCoefficient": float(getattr(it, "pricing_coefficient", 0)) if (it and getattr(it, "pricing_coefficient", None) is not None) else default_coeff
  2648. })
  2649. return success_response({
  2650. "priceListId": pl.id,
  2651. "priceVersion": pl.version,
  2652. "quotationId": pl.quotation_id,
  2653. "quotationCreateAt": quotation_create_at,
  2654. "quotationSubmitTime": quotation_submit_time,
  2655. "supplierId": pl.supplier_id,
  2656. "supplierName": supplier.real_name if supplier else None,
  2657. "supplierLogoUrl": getattr(supplier, "avatar_url", None) if supplier else None,
  2658. "items": item_details
  2659. })
  2660. @router.get("/quotation-compare", response_model=dict)
  2661. def compare_quotations(
  2662. inquiryId: str = Query(..., description="询价单ID"),
  2663. supplierIds: str = Query(..., description="供应商ID,逗号分隔"),
  2664. db: Session = Depends(get_db),
  2665. current_user: dict = Depends(require_admin)
  2666. ):
  2667. """报价对比(按询价单+供应商)"""
  2668. from app.crud.quotation import quotation_crud, quotation_item_crud
  2669. supplier_id_list = [x.strip() for x in supplierIds.split(",") if x.strip()]
  2670. if len(supplier_id_list) < 2:
  2671. return error_response(400, "至少选择两个供应商")
  2672. quotations, _ = quotation_crud.get_multi_with_filters(
  2673. db,
  2674. inquiry_id=inquiryId,
  2675. skip=0,
  2676. limit=500
  2677. )
  2678. quotations = [q for q in quotations if q.supplier_id in supplier_id_list]
  2679. if not quotations:
  2680. return success_response([])
  2681. # 汇总每个商品在不同供应商下的价格
  2682. all_items = []
  2683. for q in quotations:
  2684. items = quotation_item_crud.get_by_quotation(db, q.id)
  2685. for item in items:
  2686. all_items.append((q, item))
  2687. product_ids = [str(item.product_id) for _, item in all_items if item.product_id]
  2688. extra_unit_ids = [str(item.unit_id) for _, item in all_items if item.unit_id]
  2689. product_map, _cat_map, unit_map = _prefetch_products_categories_units(
  2690. db, product_ids, extra_unit_ids
  2691. )
  2692. compare_map = {}
  2693. for q, item in all_items:
  2694. product = product_map.get(str(item.product_id or ""))
  2695. unit_name = unit_map.get(str(item.unit_id)) if item.unit_id else None
  2696. key = f"{item.product_id}:{item.unit_id or ''}"
  2697. row = compare_map.get(key)
  2698. if not row:
  2699. row = {
  2700. "productId": item.product_id,
  2701. "productName": product.name if product else item.product_id,
  2702. "unitId": item.unit_id,
  2703. "unitName": unit_name,
  2704. "prices": {},
  2705. "lowestPrice": None,
  2706. "highestPrice": None
  2707. }
  2708. compare_map[key] = row
  2709. price_value = float(item.price) if item.price is not None else 0
  2710. row["prices"][q.supplier_id] = price_value
  2711. result = []
  2712. for _, row in compare_map.items():
  2713. price_values = [v for v in row["prices"].values() if v is not None]
  2714. if price_values:
  2715. row["lowestPrice"] = min(price_values)
  2716. row["highestPrice"] = max(price_values)
  2717. else:
  2718. row["lowestPrice"] = 0
  2719. row["highestPrice"] = 0
  2720. result.append(row)
  2721. result.sort(key=lambda x: (x.get("productName") or "", x.get("unitName") or ""))
  2722. return success_response(result)
  2723. @router.post("/quotations/{quotation_id}/approve", response_model=dict)
  2724. def approve_quotation(
  2725. quotation_id: str,
  2726. request: dict,
  2727. db: Session = Depends(get_db),
  2728. current_user: dict = Depends(require_admin)
  2729. ):
  2730. """报价无需审核(接口已停用)"""
  2731. return error_response(410, "报价无需审核:请在“询价报价管理-最新报价对比”中直接选中供应商生效价格")
  2732. @router.post("/quotations/{quotation_id}/reject", response_model=dict)
  2733. def reject_quotation(
  2734. quotation_id: str,
  2735. request: dict,
  2736. db: Session = Depends(get_db),
  2737. current_user: dict = Depends(require_admin)
  2738. ):
  2739. """报价无需审核(接口已停用)"""
  2740. return error_response(410, "报价无需审核:请在“询价报价管理-最新报价对比”中直接选中供应商生效价格")
  2741. # =========================================
  2742. # 价格清单管理 API (Web端)
  2743. # =========================================
  2744. @router.get("/price-lists", response_model=dict)
  2745. def list_price_lists(
  2746. page: int = Query(1, ge=1, description="页码"),
  2747. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  2748. regionId: Optional[str] = Query(None, description="区域ID"),
  2749. status: Optional[str] = Query(None, description="状态: draft/pending/active/expired"),
  2750. db: Session = Depends(get_db),
  2751. current_user: dict = Depends(require_admin)
  2752. ):
  2753. """获取价格清单列表"""
  2754. from app.crud.price_list import price_list_crud
  2755. from app.crud.user import user_crud
  2756. # 区域管理员只能查看自己区域的价格清单
  2757. admin_region_id = get_admin_region_filter(db, current_user)
  2758. if admin_region_id and not regionId:
  2759. regionId = admin_region_id
  2760. elif admin_region_id and regionId != admin_region_id:
  2761. return success_response({
  2762. "list": [],
  2763. "total": 0,
  2764. "page": page,
  2765. "pageSize": pageSize,
  2766. "totalPages": 0
  2767. })
  2768. price_lists, total = price_list_crud.get_multi_with_filters(
  2769. db,
  2770. region_id=regionId,
  2771. status=status,
  2772. skip=(page - 1) * pageSize,
  2773. limit=pageSize
  2774. )
  2775. region_map = {}
  2776. supplier_map = {}
  2777. publisher_map = {}
  2778. for pl in price_lists:
  2779. if pl.region_id and pl.region_id not in region_map:
  2780. region = region_crud.get(db, pl.region_id)
  2781. region_map[pl.region_id] = region.name if region else None
  2782. if pl.supplier_id and pl.supplier_id not in supplier_map:
  2783. supplier = user_crud.get(db, pl.supplier_id)
  2784. supplier_map[pl.supplier_id] = supplier.real_name if supplier else None
  2785. if pl.published_by and pl.published_by not in publisher_map:
  2786. publisher = user_crud.get(db, pl.published_by)
  2787. publisher_map[pl.published_by] = publisher.real_name if publisher else None
  2788. return success_response({
  2789. "list": [{
  2790. "id": pl.id,
  2791. "regionId": pl.region_id,
  2792. "regionName": region_map.get(pl.region_id),
  2793. "supplierId": pl.supplier_id,
  2794. "supplierName": supplier_map.get(pl.supplier_id),
  2795. "version": pl.version,
  2796. "status": pl.status,
  2797. "effectiveTime": str(pl.effective_time) if pl.effective_time else None,
  2798. "publishedBy": pl.published_by,
  2799. "publishedByName": publisher_map.get(pl.published_by),
  2800. "publishedTime": str(pl.published_time) if pl.published_time else None,
  2801. "remark": pl.remark,
  2802. "createAt": str(pl.create_at) if pl.create_at else None
  2803. } for pl in price_lists],
  2804. "total": total,
  2805. "page": page,
  2806. "pageSize": pageSize,
  2807. "totalPages": (total + pageSize - 1) // pageSize
  2808. })
  2809. @router.post("/price-lists", response_model=dict)
  2810. def create_price_list(
  2811. request: dict,
  2812. db: Session = Depends(get_db),
  2813. current_user: dict = Depends(require_admin)
  2814. ):
  2815. """创建价格清单"""
  2816. from app.crud.price_list import price_list_crud
  2817. from datetime import datetime
  2818. region_id = request.get("regionId")
  2819. supplier_id = request.get("supplierId")
  2820. items = request.get("items", [])
  2821. effective_type = request.get("effectiveType", "immediate")
  2822. effective_time_str = request.get("effectiveTime")
  2823. remark = request.get("remark")
  2824. if not region_id:
  2825. return error_response(400, "请选择区域")
  2826. if not supplier_id:
  2827. return error_response(400, "请选择供应商")
  2828. if not items:
  2829. return error_response(400, "请添加价格项")
  2830. effective_time = None
  2831. if effective_time_str:
  2832. try:
  2833. effective_time = datetime.fromisoformat(effective_time_str.replace("Z", "+00:00"))
  2834. except:
  2835. effective_time = None
  2836. creator = {
  2837. "user_id": current_user.get("user_id"),
  2838. "real_name": current_user.get("real_name")
  2839. }
  2840. price_list = price_list_crud.create_with_items(
  2841. db,
  2842. obj_in={
  2843. "region_id": region_id,
  2844. "supplier_id": supplier_id,
  2845. "effective_time": effective_time,
  2846. "remark": remark
  2847. },
  2848. items=items,
  2849. creator=creator
  2850. )
  2851. # 如果是立即生效,直接发布
  2852. if effective_type == "immediate":
  2853. price_list_crud.publish(
  2854. db,
  2855. price_list.id,
  2856. current_user.get("user_id"),
  2857. effective_type="immediate"
  2858. )
  2859. return success_response({
  2860. "id": price_list.id,
  2861. "message": "创建成功"
  2862. })
  2863. @router.get("/price-lists/{price_list_id}", response_model=dict)
  2864. def get_price_list(
  2865. price_list_id: str,
  2866. db: Session = Depends(get_db),
  2867. current_user: dict = Depends(require_admin)
  2868. ):
  2869. """获取价格清单详情"""
  2870. from app.crud.price_list import price_list_crud, price_list_item_crud
  2871. from app.crud.user import user_crud
  2872. price_list = price_list_crud.get(db, price_list_id)
  2873. if not price_list:
  2874. return error_response(404, "价格清单不存在")
  2875. items = price_list_item_crud.get_by_price_list(db, price_list_id)
  2876. # 获取关联信息
  2877. region_name = None
  2878. if price_list.region_id:
  2879. region = region_crud.get(db, price_list.region_id)
  2880. region_name = region.name if region else None
  2881. supplier_name = None
  2882. if price_list.supplier_id:
  2883. supplier = user_crud.get(db, price_list.supplier_id)
  2884. supplier_name = supplier.real_name if supplier else None
  2885. publisher_name = None
  2886. if price_list.published_by:
  2887. publisher = user_crud.get(db, price_list.published_by)
  2888. publisher_name = publisher.real_name if publisher else None
  2889. product_ids = [str(item.product_id) for item in items if item.product_id]
  2890. extra_unit_ids = [str(item.unit_id) for item in items if item.unit_id]
  2891. product_map, category_map, unit_map = _prefetch_products_categories_units(
  2892. db, product_ids, extra_unit_ids
  2893. )
  2894. item_details = []
  2895. for item in items:
  2896. product = product_map.get(str(item.product_id or ""))
  2897. category_id = str(product.category_id) if product and product.category_id else None
  2898. unit_id = str(item.unit_id) if item.unit_id else None
  2899. item_details.append({
  2900. "id": item.id,
  2901. "productId": item.product_id,
  2902. "productName": product.name if product else None,
  2903. "categoryName": category_map.get(category_id) if category_id else None,
  2904. "unitId": item.unit_id,
  2905. "unitName": unit_map.get(unit_id) if unit_id else None,
  2906. "price": float(item.price) if item.price else 0,
  2907. "imageUrl": product.image_url if product else None
  2908. })
  2909. return success_response({
  2910. "id": price_list.id,
  2911. "regionId": price_list.region_id,
  2912. "regionName": region_name,
  2913. "supplierId": price_list.supplier_id,
  2914. "supplierName": supplier_name,
  2915. "version": price_list.version,
  2916. "status": price_list.status,
  2917. "effectiveTime": str(price_list.effective_time) if price_list.effective_time else None,
  2918. "publishedBy": price_list.published_by,
  2919. "publishedByName": publisher_name,
  2920. "publishedTime": str(price_list.published_time) if price_list.published_time else None,
  2921. "remark": price_list.remark,
  2922. "createAt": str(price_list.create_at) if price_list.create_at else None,
  2923. "items": item_details
  2924. })
  2925. @router.put("/price-lists/{price_list_id}", response_model=dict)
  2926. def update_price_list(
  2927. price_list_id: str,
  2928. request: dict,
  2929. db: Session = Depends(get_db),
  2930. current_user: dict = Depends(require_admin)
  2931. ):
  2932. """更新价格清单(只有草稿状态可编辑)"""
  2933. from app.crud.price_list import price_list_crud
  2934. from datetime import datetime
  2935. price_list = price_list_crud.get(db, price_list_id)
  2936. if not price_list:
  2937. return error_response(404, "价格清单不存在")
  2938. if price_list.status != "draft":
  2939. return error_response(400, "只有草稿状态的价格清单可以编辑")
  2940. items = request.get("items")
  2941. effective_time_str = request.get("effectiveTime")
  2942. remark = request.get("remark")
  2943. obj_in = {}
  2944. if effective_time_str:
  2945. try:
  2946. obj_in["effective_time"] = datetime.fromisoformat(effective_time_str.replace("Z", "+00:00"))
  2947. except:
  2948. pass
  2949. if remark is not None:
  2950. obj_in["remark"] = remark
  2951. updater = {
  2952. "user_id": current_user.get("user_id"),
  2953. "real_name": current_user.get("real_name")
  2954. }
  2955. price_list_crud.update_with_items(
  2956. db,
  2957. price_list_id,
  2958. obj_in,
  2959. items=items,
  2960. updater=updater
  2961. )
  2962. return success_response({
  2963. "id": price_list_id,
  2964. "message": "更新成功"
  2965. })
  2966. @router.post("/price-lists/{price_list_id}/publish", response_model=dict)
  2967. def publish_price_list(
  2968. price_list_id: str,
  2969. request: dict,
  2970. db: Session = Depends(get_db),
  2971. current_user: dict = Depends(require_admin)
  2972. ):
  2973. """发布价格清单"""
  2974. from app.crud.price_list import price_list_crud
  2975. from datetime import datetime
  2976. price_list = price_list_crud.get(db, price_list_id)
  2977. if not price_list:
  2978. return error_response(404, "价格清单不存在")
  2979. if price_list.status != "draft":
  2980. return error_response(400, "只有草稿状态的价格清单可以发布")
  2981. effective_type = request.get("effectiveType", "immediate")
  2982. effective_time_str = request.get("effectiveTime")
  2983. effective_time = None
  2984. if effective_time_str:
  2985. try:
  2986. effective_time = datetime.fromisoformat(effective_time_str.replace("Z", "+00:00"))
  2987. except:
  2988. pass
  2989. price_list_crud.publish(
  2990. db,
  2991. price_list_id,
  2992. current_user.get("user_id"),
  2993. effective_time=effective_time,
  2994. effective_type=effective_type
  2995. )
  2996. # 广播:价格清单发布后,通知该区域客户端刷新价格
  2997. try:
  2998. from app.ws.price_updates import notify_price_list_updated, PriceListUpdatedEvent
  2999. db.refresh(price_list)
  3000. notify_price_list_updated(PriceListUpdatedEvent(
  3001. regionId=str(getattr(price_list, "region_id", "") or ""),
  3002. priceListId=str(getattr(price_list, "id", "") or ""),
  3003. priceVersion=int(getattr(price_list, "version", 0) or 0),
  3004. publishedTime=str(getattr(price_list, "published_time", None) or getattr(price_list, "publish_time", None) or ""),
  3005. ))
  3006. except Exception:
  3007. # 推送失败不影响主流程
  3008. logger.exception(
  3009. "[ws-mini] notify failed in publish_price_list price_list_id=%s",
  3010. price_list_id,
  3011. )
  3012. return success_response({
  3013. "id": price_list_id,
  3014. "message": "发布成功"
  3015. })
  3016. @router.post("/price-lists/{price_list_id}/invalidate", response_model=dict)
  3017. def invalidate_price_list(
  3018. price_list_id: str,
  3019. db: Session = Depends(get_db),
  3020. current_user: dict = Depends(require_admin)
  3021. ):
  3022. """使价格清单失效"""
  3023. from app.crud.price_list import price_list_crud
  3024. price_list = price_list_crud.get(db, price_list_id)
  3025. if not price_list:
  3026. return error_response(404, "价格清单不存在")
  3027. if price_list.status not in ["active", "pending"]:
  3028. return error_response(400, "只有生效中或待生效的价格清单可以失效")
  3029. price_list_crud.invalidate(db, price_list_id)
  3030. return success_response({
  3031. "id": price_list_id,
  3032. "message": "操作成功"
  3033. })
  3034. @router.delete("/price-lists/{price_list_id}", response_model=dict)
  3035. def delete_price_list(
  3036. price_list_id: str,
  3037. db: Session = Depends(get_db),
  3038. current_user: dict = Depends(require_admin)
  3039. ):
  3040. """删除价格清单(只有草稿状态可删除)"""
  3041. from app.crud.price_list import price_list_crud
  3042. price_list = price_list_crud.get(db, price_list_id)
  3043. if not price_list:
  3044. return error_response(404, "价格清单不存在")
  3045. if price_list.status != "draft":
  3046. return error_response(400, "只有草稿状态的价格清单可以删除")
  3047. price_list_crud.delete(db, price_list_id)
  3048. return success_response({
  3049. "message": "删除成功"
  3050. })
  3051. @router.get("/price-lists/product-history", response_model=dict)
  3052. def get_product_price_history(
  3053. regionId: str = Query(..., description="区域ID"),
  3054. productId: str = Query(..., description="商品ID"),
  3055. page: int = Query(1, ge=1, description="页码"),
  3056. pageSize: int = Query(20, ge=1, le=100, description="每页条数"),
  3057. db: Session = Depends(get_db),
  3058. current_user: dict = Depends(require_admin)
  3059. ):
  3060. """按商品维度查询价格历史"""
  3061. from app.crud.price_list import price_list_crud, price_list_item_crud
  3062. product = product_crud.get(db, productId)
  3063. if not product:
  3064. return error_response(404, "商品不存在")
  3065. items, total = price_list_item_crud.get_product_price_history(
  3066. db,
  3067. region_id=regionId,
  3068. product_id=productId,
  3069. skip=(page - 1) * pageSize,
  3070. limit=pageSize
  3071. )
  3072. # 获取单位信息
  3073. for item in items:
  3074. if item.get("unit_id"):
  3075. u = unit_crud.get(db, item["unit_id"])
  3076. item["unitName"] = u.name if u else None
  3077. return success_response({
  3078. "productId": productId,
  3079. "productName": product.name,
  3080. "history": items,
  3081. "total": total,
  3082. "page": page,
  3083. "pageSize": pageSize
  3084. })
  3085. @router.get("/price-lists/generate-options", response_model=dict)
  3086. def get_generate_price_list_options(
  3087. regionId: str = Query(..., description="区域ID"),
  3088. db: Session = Depends(get_db),
  3089. current_user: dict = Depends(require_admin)
  3090. ):
  3091. """获取生成价格清单的选项(该区域已提交、尚未被选中的报价单,报价无审核流程)"""
  3092. from app.crud.quotation import quotation_crud, quotation_item_crud
  3093. from app.crud.user import user_crud
  3094. quotations, total = quotation_crud.get_multi_with_filters(
  3095. db,
  3096. region_id=regionId,
  3097. status="submitted",
  3098. skip=0,
  3099. limit=100
  3100. )
  3101. result = []
  3102. all_items_by_qid: dict[str, list] = {}
  3103. all_product_ids: list[str] = []
  3104. all_unit_ids: list[str] = []
  3105. supplier_ids = [q.supplier_id for q in quotations if q.supplier_id]
  3106. supplier_map = {}
  3107. if supplier_ids:
  3108. from app.models.models import SysUser
  3109. suppliers = db.query(SysUser).filter(
  3110. and_(SysUser.id.in_(supplier_ids), SysUser.is_del == 0)
  3111. ).all()
  3112. supplier_map = {str(s.id): s for s in suppliers}
  3113. for q in quotations:
  3114. items = quotation_item_crud.get_by_quotation(db, q.id)
  3115. all_items_by_qid[q.id] = items
  3116. for item in items:
  3117. if item.product_id:
  3118. all_product_ids.append(str(item.product_id))
  3119. if item.unit_id:
  3120. all_unit_ids.append(str(item.unit_id))
  3121. product_map, _cat_map, unit_map = _prefetch_products_categories_units(
  3122. db, all_product_ids, all_unit_ids
  3123. )
  3124. for q in quotations:
  3125. supplier = supplier_map.get(str(q.supplier_id or ""))
  3126. items = all_items_by_qid.get(q.id, [])
  3127. item_details = []
  3128. for item in items:
  3129. product = product_map.get(str(item.product_id or ""))
  3130. unit_name = unit_map.get(str(item.unit_id)) if item.unit_id else None
  3131. item_details.append({
  3132. "productId": item.product_id,
  3133. "productName": product.name if product else None,
  3134. "unitId": item.unit_id,
  3135. "unitName": unit_name,
  3136. "price": float(item.price) if item.price else 0
  3137. })
  3138. result.append({
  3139. "quotationId": q.id,
  3140. "quotationNo": f"QT{q.create_at.strftime('%Y%m%d%H%M%S') if q.create_at else ''}",
  3141. "supplierId": q.supplier_id,
  3142. "supplierName": supplier.real_name if supplier else None,
  3143. "totalAmount": float(q.total_amount) if q.total_amount else 0,
  3144. "items": item_details
  3145. })
  3146. return success_response({
  3147. "list": result,
  3148. "total": len(result)
  3149. })
  3150. @router.get("/price-lists/compare", response_model=dict)
  3151. def compare_price_list_versions(
  3152. regionId: str = Query(..., description="区域ID"),
  3153. version1Id: str = Query(..., description="版本1ID"),
  3154. version2Id: str = Query(..., description="版本2ID"),
  3155. db: Session = Depends(get_db),
  3156. current_user: dict = Depends(require_admin)
  3157. ):
  3158. """对比两个价格版本"""
  3159. from app.crud.price_list import price_list_crud, price_list_item_crud
  3160. # 获取两个版本的价格清单
  3161. price_list1 = price_list_crud.get(db, version1Id)
  3162. price_list2 = price_list_crud.get(db, version2Id)
  3163. if not price_list1 or not price_list2:
  3164. return error_response(404, "价格版本不存在")
  3165. if price_list1.region_id != regionId or price_list2.region_id != regionId:
  3166. return error_response(400, "价格版本不属于该区域")
  3167. # 获取两个版本的价格项
  3168. items1 = price_list_item_crud.get_by_price_list(db, version1Id)
  3169. items2 = price_list_item_crud.get_by_price_list(db, version2Id)
  3170. # 构建价格映射
  3171. price_map1 = {item.product_id: item for item in items1}
  3172. price_map2 = {item.product_id: item for item in items2}
  3173. # 获取所有涉及的商品
  3174. all_product_ids = set(price_map1.keys()) | set(price_map2.keys())
  3175. extra_unit_ids = []
  3176. for item in list(items1) + list(items2):
  3177. if item.unit_id:
  3178. extra_unit_ids.append(str(item.unit_id))
  3179. product_map, _cat_map, unit_map = _prefetch_products_categories_units(
  3180. db, [str(x) for x in all_product_ids], extra_unit_ids
  3181. )
  3182. # 构建对比数据
  3183. comparison = []
  3184. for product_id in all_product_ids:
  3185. item1 = price_map1.get(product_id)
  3186. item2 = price_map2.get(product_id)
  3187. product = product_map.get(str(product_id or ""))
  3188. unit_id = item1.unit_id if item1 else (item2.unit_id if item2 else None)
  3189. unit_name = unit_map.get(str(unit_id)) if unit_id else None
  3190. price1 = float(item1.price) if item1 and item1.price else None
  3191. price2 = float(item2.price) if item2 and item2.price else None
  3192. # 计算差异
  3193. price_diff = None
  3194. price_change = None
  3195. change_rate = None
  3196. if price1 is not None and price2 is not None:
  3197. price_diff = price2 - price1
  3198. if price1 > 0:
  3199. change_rate = round((price_diff / price1) * 100, 2)
  3200. if price_diff > 0:
  3201. price_change = "up"
  3202. elif price_diff < 0:
  3203. price_change = "down"
  3204. else:
  3205. price_change = "same"
  3206. comparison.append({
  3207. "productId": product_id,
  3208. "productName": product.name if product else None,
  3209. "categoryId": product.category_id if product else None,
  3210. "unitId": unit_id,
  3211. "unitName": unit_name,
  3212. "price1": price1,
  3213. "price2": price2,
  3214. "priceDiff": price_diff,
  3215. "priceChange": price_change,
  3216. "changeRate": change_rate,
  3217. "existIn1": item1 is not None,
  3218. "existIn2": item2 is not None
  3219. })
  3220. # 计算汇总统计
  3221. total_products = len(comparison)
  3222. price_up_count = sum(1 for c in comparison if c["priceChange"] == "up")
  3223. price_down_count = sum(1 for c in comparison if c["priceChange"] == "down")
  3224. price_same_count = sum(1 for c in comparison if c["priceChange"] == "same")
  3225. new_product_count = sum(1 for c in comparison if not c["existIn1"] and c["existIn2"])
  3226. removed_product_count = sum(1 for c in comparison if c["existIn1"] and not c["existIn2"])
  3227. return success_response({
  3228. "version1": {
  3229. "id": price_list1.id,
  3230. "version": price_list1.version,
  3231. "status": price_list1.status,
  3232. "publishedTime": str(price_list1.published_time) if price_list1.published_time else None
  3233. },
  3234. "version2": {
  3235. "id": price_list2.id,
  3236. "version": price_list2.version,
  3237. "status": price_list2.status,
  3238. "publishedTime": str(price_list2.published_time) if price_list2.published_time else None
  3239. },
  3240. "statistics": {
  3241. "totalProducts": total_products,
  3242. "priceUp": price_up_count,
  3243. "priceDown": price_down_count,
  3244. "priceSame": price_same_count,
  3245. "newProducts": new_product_count,
  3246. "removedProducts": removed_product_count
  3247. },
  3248. "comparison": comparison
  3249. })
  3250. @router.get("/price-lists/history", response_model=dict)
  3251. def get_price_list_history(
  3252. regionId: str = Query(..., description="区域ID"),
  3253. page: int = Query(1, ge=1, description="页码"),
  3254. pageSize: int = Query(10, ge=1, le=50, description="每页条数"),
  3255. db: Session = Depends(get_db),
  3256. current_user: dict = Depends(require_admin)
  3257. ):
  3258. """获取价格清单历史版本列表"""
  3259. from app.crud.price_list import price_list_crud
  3260. from app.crud.user import user_crud
  3261. price_lists, total = price_list_crud.get_multi_with_filters(
  3262. db,
  3263. region_id=regionId,
  3264. skip=(page - 1) * pageSize,
  3265. limit=pageSize
  3266. )
  3267. result = []
  3268. for pl in price_lists:
  3269. supplier = user_crud.get(db, pl.supplier_id)
  3270. region = region_crud.get(db, pl.region_id)
  3271. result.append({
  3272. "id": pl.id,
  3273. "version": pl.version,
  3274. "status": pl.status,
  3275. "statusText": "生效中" if pl.status == "active" else "待生效" if pl.status == "pending" else "已失效" if pl.status == "expired" else "草稿",
  3276. "supplierId": pl.supplier_id,
  3277. "supplierName": supplier.real_name if supplier else None,
  3278. "regionName": region.name if region else None,
  3279. "effectiveTime": str(pl.effective_time) if pl.effective_time else None,
  3280. "publishedTime": str(pl.published_time) if pl.published_time else None,
  3281. "remark": pl.remark,
  3282. "createAt": str(pl.create_at) if pl.create_at else None
  3283. })
  3284. return success_response({
  3285. "list": result,
  3286. "total": total,
  3287. "page": page,
  3288. "pageSize": pageSize
  3289. })
  3290. # =========================================
  3291. # 订单管理 API (Web端)
  3292. # =========================================
  3293. # 订单状态映射
  3294. ORDER_STATUS_MAP = {
  3295. "pending_payment": "待支付",
  3296. "pending_review_cancel": "待审核取消",
  3297. "pending_dispatch": "待分配配送",
  3298. "delivering": "配送中",
  3299. "pending_confirm": "待确认收货",
  3300. "completed": "已完结",
  3301. "cancelled": "已取消"
  3302. }
  3303. # 订单状态日志展示(含配送/支付维度的结构化 to_status)
  3304. DELIVERY_STATUS_LOG_LABELS = {
  3305. "pending_dispatch": "待分配配送",
  3306. "delivering": "配送中",
  3307. "delivered": "配送完成",
  3308. }
  3309. def _order_log_status_display_map() -> dict:
  3310. m = dict(ORDER_STATUS_MAP)
  3311. m["delivery.assign"] = "分配配送员"
  3312. m["delivery.reassign"] = "更换配送员"
  3313. for k, v in DELIVERY_STATUS_LOG_LABELS.items():
  3314. m[f"ds_{k}"] = f"配送状态:{v}"
  3315. m["pay_pending_payment"] = "支付状态:待支付"
  3316. m["pay_paid"] = "支付状态:已支付"
  3317. m["pay_refunding"] = "支付状态:退款中"
  3318. m["pay_refunded"] = "支付状态:已退款"
  3319. _task_lbl = {
  3320. "pending": "待分配",
  3321. "assigned": "已分配",
  3322. "supplier_stocking": "供应商备货中",
  3323. "supplier_ready": "备货完成(待取货)",
  3324. "picking": "取货中",
  3325. "delivering": "配送中",
  3326. "completed": "已完成",
  3327. "cancelled": "已取消",
  3328. }
  3329. for k, v in _task_lbl.items():
  3330. m[f"dt_{k}"] = f"配送任务:{v}"
  3331. return m
  3332. ORDER_LOG_STATUS_MAP = _order_log_status_display_map()
  3333. def _status_log_timeline_description(entry: dict) -> str:
  3334. """与 web-admin 订单详情「操作记录」中对 reason/note 的展示规则保持一致。"""
  3335. note = (entry.get("note") or "").strip()
  3336. raw_reason = entry.get("reason")
  3337. description = note or ""
  3338. if raw_reason and str(raw_reason).strip().startswith("{"):
  3339. try:
  3340. o = json.loads(raw_reason)
  3341. if isinstance(o, dict) and (
  3342. o.get("toDeliverName") is not None or o.get("fromDeliverName") is not None
  3343. ):
  3344. description = f"配送员:{o.get('fromDeliverName') or '—'} → {o.get('toDeliverName') or '—'}"
  3345. if note:
  3346. description += f";{note}"
  3347. except json.JSONDecodeError:
  3348. if not description:
  3349. description = str(raw_reason)
  3350. elif raw_reason and not description:
  3351. description = str(raw_reason)
  3352. return description
  3353. def _delivery_timeline_type(to_status: str) -> str:
  3354. if to_status == "dt_completed":
  3355. return "success"
  3356. if to_status == "dt_cancelled":
  3357. return "danger"
  3358. return "primary"
  3359. def _log_entry_in_delivery_timeline(entry: dict) -> bool:
  3360. ts = entry.get("status") or ""
  3361. if ts in ("delivery.assign", "delivery.reassign"):
  3362. return True
  3363. if ts.startswith("dt_") or ts.startswith("ds_"):
  3364. return True
  3365. return False
  3366. def _build_order_delivery_timeline(dtask, log_details: List[dict], order) -> List[dict]:
  3367. """
  3368. 配送进度以订单状态日志为权威来源(配送任务 dt_*、配送状态 ds_*、分配配送员等),
  3369. 与「操作记录」同源,避免两套拼接逻辑长期漂移。
  3370. """
  3371. events: List[dict] = []
  3372. if dtask is not None and dtask.create_at:
  3373. dr = (getattr(dtask, "dispatch_remark", None) or "").strip() or None
  3374. events.append({
  3375. "time": str(dtask.create_at),
  3376. "title": "配送任务已创建",
  3377. "description": dr or "",
  3378. "type": "info",
  3379. "hollow": False,
  3380. })
  3381. timeline_statuses: set = set()
  3382. for entry in log_details:
  3383. if not _log_entry_in_delivery_timeline(entry):
  3384. continue
  3385. ts_key = entry.get("status") or ""
  3386. timeline_statuses.add(ts_key)
  3387. t = entry.get("createAt")
  3388. if not t:
  3389. continue
  3390. from_t = (entry.get("fromStatusText") or "").strip()
  3391. to_t = (entry.get("statusText") or "").strip() or ts_key or "-"
  3392. title = f"{from_t} → {to_t}" if from_t else to_t
  3393. events.append({
  3394. "time": t,
  3395. "title": title,
  3396. "description": _status_log_timeline_description(entry),
  3397. "type": _delivery_timeline_type(ts_key),
  3398. "hollow": False,
  3399. })
  3400. if dtask is not None:
  3401. if dtask.pickup_time and "dt_picking" not in timeline_statuses:
  3402. pr = (dtask.pickup_remark or "").strip() or ""
  3403. events.append({
  3404. "time": str(dtask.pickup_time),
  3405. "title": "配送任务:取货中",
  3406. "description": pr,
  3407. "type": "primary",
  3408. "hollow": False,
  3409. })
  3410. if "dt_completed" not in timeline_statuses and (
  3411. dtask.status == "completed"
  3412. or (getattr(dtask, "deliverer_status", None) == "delivered")
  3413. ):
  3414. t_end = dtask.delivery_time or getattr(order, "delivered_time", None)
  3415. if t_end:
  3416. dr = (dtask.delivery_remark or "").strip() or ""
  3417. events.append({
  3418. "time": str(t_end),
  3419. "title": "配送任务:已完成",
  3420. "description": dr,
  3421. "type": "success",
  3422. "hollow": False,
  3423. })
  3424. events = [e for e in events if e.get("time")]
  3425. events.sort(key=lambda x: str(x.get("time") or ""))
  3426. return events
  3427. @router.get("/orders", response_model=dict)
  3428. def list_orders(
  3429. page: int = Query(1, ge=1, description="页码"),
  3430. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  3431. regionId: Optional[str] = Query(None, description="区域ID"),
  3432. status: Optional[str] = Query(None, description="订单状态"),
  3433. orderStatus: Optional[str] = Query(None, description="订单主状态"),
  3434. deliveryStatus: Optional[str] = Query(None, description="配送状态"),
  3435. payStatus: Optional[str] = Query(None, description="支付状态"),
  3436. paymentMethod: Optional[str] = Query(None, description="支付方式: online/offline"),
  3437. clientId: Optional[str] = Query(None, description="客户ID"),
  3438. orderNo: Optional[str] = Query(None, description="订单号(模糊,兼容旧参数)"),
  3439. keyword: Optional[str] = Query(None, description="关键词:订单编号/联系人/电话/客户姓名或用户名等"),
  3440. startDate: Optional[str] = Query(None, description="开始日期"),
  3441. endDate: Optional[str] = Query(None, description="结束日期"),
  3442. db: Session = Depends(get_db),
  3443. current_user: dict = Depends(require_admin)
  3444. ):
  3445. """获取订单列表"""
  3446. from app.crud.order import order_crud, order_item_crud
  3447. from sqlalchemy import func
  3448. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  3449. if allowed_region_ids is not None:
  3450. if regionId and regionId not in allowed_region_ids:
  3451. return error_response(403, "无权访问该区域数据")
  3452. if not regionId and allowed_region_ids:
  3453. regionId = sorted(allowed_region_ids)[0]
  3454. orders, total = order_crud.get_multi_with_filters(
  3455. db,
  3456. region_id=regionId,
  3457. status=status,
  3458. order_status=orderStatus,
  3459. delivery_status=deliveryStatus,
  3460. pay_status=payStatus,
  3461. payment_method=paymentMethod,
  3462. client_id=clientId,
  3463. order_no=orderNo,
  3464. keyword=keyword,
  3465. start_date=startDate,
  3466. end_date=endDate,
  3467. skip=(page - 1) * pageSize,
  3468. limit=pageSize
  3469. )
  3470. # 列表页:按订单行汇总供货总金额 / 售出总金额(与订单详情口径一致)
  3471. order_ids_page = [o.id for o in orders]
  3472. oid_to_order = {o.id: o for o in orders}
  3473. items_by_oid: dict = {}
  3474. if order_ids_page:
  3475. for ln in (
  3476. db.query(BizOrderItem)
  3477. .filter(BizOrderItem.order_id.in_(order_ids_page), BizOrderItem.is_del == 0)
  3478. .all()
  3479. ):
  3480. items_by_oid.setdefault(ln.order_id, []).append(ln)
  3481. pl_ids_page = list({o.price_list_id for o in orders if o.price_list_id})
  3482. pli_map_page = _price_list_item_map(db, pl_ids_page) if pl_ids_page else {}
  3483. order_supplier_selling_totals: dict = {}
  3484. for oid, lines in items_by_oid.items():
  3485. o = oid_to_order.get(oid)
  3486. pl_id = o.price_list_id if o else None
  3487. sup_acc = 0.0
  3488. sel_acc = 0.0
  3489. for item in lines:
  3490. qty = float(item.quantity or 0)
  3491. selling_u = float(item.unit_price or 0)
  3492. selling_amt = (
  3493. float(item.amount) if item.amount is not None else round(qty * selling_u, 2)
  3494. )
  3495. pli = pli_map_page.get((pl_id, item.product_id, item.unit_id)) if pl_id else None
  3496. supplier_u = _supplier_unit_yuan_from_pli(pli)
  3497. if supplier_u is None:
  3498. supplier_u = selling_u
  3499. sup_acc += round(qty * supplier_u, 2)
  3500. sel_acc += selling_amt
  3501. order_supplier_selling_totals[oid] = (round(sup_acc, 2), round(sel_acc, 2))
  3502. region_map = {}
  3503. region_delivery_minutes_map = {}
  3504. client_map = {}
  3505. item_count_map: dict[str, int] = {}
  3506. if order_ids_page:
  3507. # 避免 N+1:批量聚合订单项数量
  3508. for row in (
  3509. db.query(BizOrderItem.order_id, func.count(BizOrderItem.id))
  3510. .filter(BizOrderItem.is_del == 0, BizOrderItem.order_id.in_(order_ids_page))
  3511. .group_by(BizOrderItem.order_id)
  3512. .all()
  3513. ):
  3514. item_count_map[str(row[0])] = int(row[1] or 0)
  3515. result = []
  3516. for order in orders:
  3517. order_status = getattr(order, "order_status", None) or "pending_confirm"
  3518. delivery_status = getattr(order, "delivery_status", None) or "pending_dispatch"
  3519. pay_status = getattr(order, "pay_status", None) or "pending_payment"
  3520. # 获取区域名称、预计配送时长
  3521. if order.region_id and order.region_id not in region_map:
  3522. region = region_crud.get(db, order.region_id)
  3523. region_map[order.region_id] = region.name if region else None
  3524. region_delivery_minutes_map[order.region_id] = (
  3525. getattr(region, "expected_delivery_minutes", None) if region else None
  3526. )
  3527. # 获取客户名称
  3528. if order.client_id and order.client_id not in client_map:
  3529. client = user_crud.get(db, order.client_id)
  3530. client_map[order.client_id] = client.real_name if client else None
  3531. item_count = item_count_map.get(order.id, 0)
  3532. _sup_total, _sel_total = order_supplier_selling_totals.get(order.id, (0.0, 0.0))
  3533. result.append({
  3534. "id": order.id,
  3535. "orderNo": order.order_no,
  3536. "clientId": order.client_id,
  3537. "clientName": client_map.get(order.client_id),
  3538. "regionId": order.region_id,
  3539. "regionName": region_map.get(order.region_id),
  3540. "regionExpectedDeliveryMinutes": region_delivery_minutes_map.get(order.region_id),
  3541. "totalAmount": float(order.total_amount) if order.total_amount else 0,
  3542. "supplierTotalAmount": _sup_total,
  3543. "sellingTotalAmount": _sel_total,
  3544. "paidAmount": float(order.paid_amount) if order.paid_amount else 0,
  3545. "receivableAmount": float(order.receivable_amount) if order.receivable_amount else 0,
  3546. "status": order.status,
  3547. "orderStatus": order_status,
  3548. "deliveryStatus": delivery_status,
  3549. "payStatus": pay_status,
  3550. "cancelStatus": getattr(order, "cancel_status", None),
  3551. "paymentMethod": order_crud.normalize_payment_method(order.payment_method),
  3552. "clientSettlementStatus": getattr(order, "client_settlement_status", None) or "pending",
  3553. "supplierSettlementStatus": getattr(order, "supplier_settlement_status", None) or "pending",
  3554. "paymentStatus": "paid" if order.paid_amount and order.paid_amount > 0 else "unpaid",
  3555. "itemCount": item_count,
  3556. "createAt": str(order.create_at) if order.create_at else None,
  3557. "paymentTime": str(order.payment_time) if order.payment_time else None
  3558. })
  3559. return success_response({
  3560. "list": result,
  3561. "total": total,
  3562. "page": page,
  3563. "pageSize": pageSize,
  3564. "totalPages": (total + pageSize - 1) // pageSize
  3565. })
  3566. def _build_order_lock_info(db: Session, order) -> dict:
  3567. """只读组装订单编辑锁信息;过期锁顺手清空。"""
  3568. from app.crud.order import order_crud
  3569. empty = {
  3570. "isLocked": False,
  3571. "lockBy": None,
  3572. "lockById": None,
  3573. "lockTime": None,
  3574. "isExpired": False,
  3575. }
  3576. if not order or not getattr(order, "lock_operator_id", None):
  3577. return empty
  3578. if order_crud.is_order_lock_expired(order):
  3579. order_crud.clear_order_lock(db, order)
  3580. return {
  3581. "isLocked": False,
  3582. "lockBy": None,
  3583. "lockById": None,
  3584. "lockTime": None,
  3585. "isExpired": True,
  3586. }
  3587. lock_user = user_crud.get(db, order.lock_operator_id)
  3588. lock_by = (
  3589. (lock_user.real_name or lock_user.username)
  3590. if lock_user
  3591. else order.lock_operator_id
  3592. )
  3593. return {
  3594. "isLocked": True,
  3595. "lockBy": lock_by,
  3596. "lockById": order.lock_operator_id,
  3597. "lockTime": order.lock_time.strftime("%Y-%m-%d %H:%M:%S") if order.lock_time else None,
  3598. "isExpired": False,
  3599. }
  3600. def _ensure_order_write_lock(db: Session, order, operator_id: str):
  3601. """写操作前占锁;成功返回 None,失败返回 error_response。"""
  3602. from app.crud.order import order_crud
  3603. if not order:
  3604. return error_response(404, "订单不存在")
  3605. try:
  3606. order_crud.lock_order(db, order.id, operator_id)
  3607. db.refresh(order)
  3608. return None
  3609. except ValueError:
  3610. info = _build_order_lock_info(db, order)
  3611. who = info.get("lockBy") or getattr(order, "lock_operator_id", None) or "其他操作员"
  3612. when = info.get("lockTime") or "未知"
  3613. return error_response(400, f"订单已被 {who} 于 {when} 锁定,请先解锁后再操作")
  3614. @router.get("/orders/{order_id}", response_model=dict)
  3615. def get_order(
  3616. order_id: str,
  3617. db: Session = Depends(get_db),
  3618. current_user: dict = Depends(require_admin)
  3619. ):
  3620. """获取订单详情"""
  3621. from app.crud.order import order_crud, order_item_crud, order_status_log_crud
  3622. order = order_crud.get(db, order_id)
  3623. if not order:
  3624. return error_response(404, "订单不存在")
  3625. # 获取订单项
  3626. items = order_item_crud.get_by_order(db, order_id)
  3627. from app.crud.price_list import price_list_crud
  3628. price_list = price_list_crud.get(db, order.price_list_id) if order.price_list_id else None
  3629. supplier_id_from_price_list = getattr(price_list, "supplier_id", None) if price_list else None
  3630. supplier_name_from_price_list = None
  3631. if supplier_id_from_price_list:
  3632. _sup_u = user_crud.get(db, supplier_id_from_price_list)
  3633. if _sup_u:
  3634. supplier_name_from_price_list = _sup_u.real_name or _sup_u.username
  3635. pl_ids_for_order = [order.price_list_id] if order.price_list_id else []
  3636. pli_map_for_order = _price_list_item_map(db, pl_ids_for_order)
  3637. # 获取状态日志
  3638. status_logs = order_status_log_crud.get_by_order(db, order_id)
  3639. # 只读返回锁信息(查看详情不再加锁)
  3640. lock_info = _build_order_lock_info(db, order)
  3641. # 获取关联信息
  3642. region_name = None
  3643. region_expected_delivery_minutes = None
  3644. if order.region_id:
  3645. region = region_crud.get(db, order.region_id)
  3646. region_name = region.name if region else None
  3647. region_expected_delivery_minutes = (
  3648. getattr(region, "expected_delivery_minutes", None) if region else None
  3649. )
  3650. client_name = None
  3651. if order.client_id:
  3652. client = user_crud.get(db, order.client_id)
  3653. client_name = client.real_name if client else None
  3654. # 配送任务明细上的已送达量(订单行 delivered_quantity 可能未同步,展示时与任务明细取大)
  3655. from app.crud.delivery import delivery_task_item_crud as _merge_task_delivered
  3656. _line_delivered_from_task = _merge_task_delivered.get_delivered_quantity_by_order_item(db, order_id)
  3657. product_ids = [str(item.product_id) for item in items if item.product_id]
  3658. extra_unit_ids = [str(item.unit_id) for item in items if item.unit_id]
  3659. product_map, _category_name_map, unit_map = _prefetch_products_categories_units(
  3660. db, product_ids, extra_unit_ids
  3661. )
  3662. category_path_map = _build_category_path_map(
  3663. db,
  3664. [
  3665. str(p.category_id)
  3666. for p in product_map.values()
  3667. if getattr(p, "category_id", None)
  3668. ],
  3669. )
  3670. # 获取商品和单位信息
  3671. item_details = []
  3672. supplier_total_amount = 0.0
  3673. selling_total_amount = 0.0
  3674. for item in items:
  3675. product = product_map.get(str(item.product_id or ""))
  3676. unit_name = unit_map.get(str(item.unit_id)) if item.unit_id else None
  3677. category_id = None
  3678. category_path = ""
  3679. image_url = None
  3680. measure_value = None
  3681. if product:
  3682. category_id = getattr(product, "category_id", None)
  3683. if category_id:
  3684. category_path = category_path_map.get(str(category_id), "")
  3685. image_url = getattr(product, "image_url", None)
  3686. measure_value = _numeric_to_float_optional(getattr(product, "measure_value", None))
  3687. _oi_dq = float(item.delivered_quantity) if item.delivered_quantity else 0.0
  3688. _ti_dq = float(_line_delivered_from_task.get(item.id, 0.0))
  3689. _merged_dq = max(_oi_dq, _ti_dq)
  3690. qty = float(item.quantity) if item.quantity else 0
  3691. selling_u = float(item.unit_price) if item.unit_price else 0
  3692. selling_amt = float(item.amount) if item.amount else round(qty * selling_u, 2)
  3693. pli = pli_map_for_order.get((order.price_list_id, item.product_id, item.unit_id))
  3694. supplier_u = _supplier_unit_yuan_from_pli(pli)
  3695. if supplier_u is None:
  3696. supplier_u = selling_u
  3697. supplier_amt = round(qty * supplier_u, 2)
  3698. supplier_total_amount += supplier_amt
  3699. selling_total_amount += selling_amt
  3700. item_details.append({
  3701. "id": item.id,
  3702. "productId": item.product_id,
  3703. "productName": product.name if product else None,
  3704. "imageUrl": image_url,
  3705. "categoryId": str(category_id) if category_id else None,
  3706. "categoryName": category_path or None,
  3707. "measureValue": measure_value,
  3708. "spec": getattr(product, "spec", None) if product else None,
  3709. "unitId": item.unit_id,
  3710. "unitName": unit_name,
  3711. "supplierId": supplier_id_from_price_list,
  3712. "supplierName": supplier_name_from_price_list,
  3713. "quantity": qty,
  3714. "unitPrice": selling_u,
  3715. "amount": selling_amt,
  3716. "sellingUnitPrice": selling_u,
  3717. "sellingAmount": selling_amt,
  3718. "supplierUnitPrice": supplier_u,
  3719. "supplierAmount": supplier_amt,
  3720. "deliveredQuantity": _merged_dq,
  3721. "status": item.status
  3722. })
  3723. order_status = getattr(order, "order_status", None) or "pending_confirm"
  3724. delivery_status = getattr(order, "delivery_status", None) or "pending_dispatch"
  3725. pay_status = getattr(order, "pay_status", None) or "pending_payment"
  3726. # 处理状态日志(含配送任务、配送员分配等结构化记录)
  3727. log_details = []
  3728. for log in status_logs:
  3729. log_details.append({
  3730. "fromStatus": log.from_status,
  3731. "fromStatusText": ORDER_LOG_STATUS_MAP.get(
  3732. log.from_status, log.from_status or ""
  3733. ),
  3734. "status": log.to_status,
  3735. "statusText": ORDER_LOG_STATUS_MAP.get(log.to_status, log.to_status),
  3736. # BizOrderStatusLog 无 operator_name 字段,优先使用 create_name
  3737. "operatorName": getattr(log, "create_name", None) or getattr(log, "operator_id", None),
  3738. "operatorRole": log.operator_role,
  3739. "reason": log.reason,
  3740. "note": log.note,
  3741. "createAt": str(log.create_at) if log.create_at else None
  3742. })
  3743. deliver_id_out = None
  3744. deliver_name = None
  3745. deliver_phone = None
  3746. expected_time = None
  3747. delivery_dispatch_remark = None
  3748. delivery_timeline = []
  3749. supplier_id_for_pickup = None
  3750. supplier_name = None
  3751. supplier_phone = None
  3752. supplier_pickup = {"fullAddress": None, "contactPerson": None, "contactPhone": None}
  3753. delivery_task_no = None
  3754. delivery_task_status = None
  3755. delivery_task_deliverer_status = None
  3756. delivery_task_supplier_prepare_status = None
  3757. delivery_pickup_time = None
  3758. delivery_pickup_remark = None
  3759. delivery_delivery_time = None
  3760. delivery_delivery_remark = None
  3761. delivery_prepare_images = []
  3762. delivery_pickup_images = []
  3763. delivery_deliver_images = []
  3764. from app.crud.delivery import delivery_task_crud as _order_delivery_task_crud
  3765. dtask = _order_delivery_task_crud.get_by_order(db, order_id)
  3766. if dtask:
  3767. deliver_id_out = dtask.deliver_id
  3768. delivery_task_no = dtask.task_no
  3769. delivery_task_status = dtask.status
  3770. delivery_task_deliverer_status = getattr(dtask, "deliverer_status", None)
  3771. delivery_task_supplier_prepare_status = getattr(dtask, "supplier_prepare_status", None)
  3772. delivery_pickup_time = str(dtask.pickup_time) if dtask.pickup_time else None
  3773. delivery_pickup_remark = dtask.pickup_remark
  3774. delivery_delivery_time = str(dtask.delivery_time) if dtask.delivery_time else None
  3775. delivery_delivery_remark = dtask.delivery_remark
  3776. if dtask.deliver_id:
  3777. du = user_crud.get(db, dtask.deliver_id)
  3778. if du:
  3779. deliver_name = du.real_name
  3780. deliver_phone = du.phone
  3781. expected_time = str(dtask.effective_time) if dtask.effective_time else None
  3782. delivery_dispatch_remark = getattr(dtask, "dispatch_remark", None) or None
  3783. supplier_id_for_pickup = dtask.supplier_id
  3784. delivery_timeline = _build_order_delivery_timeline(dtask, log_details, order)
  3785. from app.crud.attachment import attachment_crud as _attachment_crud
  3786. delivery_prepare_images = [
  3787. a.file_url for a in _attachment_crud.get_by_business(db, "delivery_prepare", dtask.id) if a.file_url
  3788. ]
  3789. delivery_pickup_images = [
  3790. a.file_url for a in _attachment_crud.get_by_business(db, "delivery_pickup", dtask.id) if a.file_url
  3791. ]
  3792. delivery_deliver_images = [
  3793. a.file_url for a in _attachment_crud.get_by_business(db, "delivery_deliver", dtask.id) if a.file_url
  3794. ]
  3795. # 兼容旧数据:历史仅写在任务字段中
  3796. if not delivery_pickup_images and getattr(dtask, "pickup_image_url", None):
  3797. delivery_pickup_images = [
  3798. s.strip() for s in str(dtask.pickup_image_url).split(",") if s and str(s).strip()
  3799. ]
  3800. if not delivery_deliver_images and getattr(dtask, "delivery_image_url", None):
  3801. delivery_deliver_images = [
  3802. s.strip() for s in str(dtask.delivery_image_url).split(",") if s and str(s).strip()
  3803. ]
  3804. else:
  3805. from app.crud.price_list import price_list_crud
  3806. _pl = price_list_crud.get(db, order.price_list_id)
  3807. if _pl and getattr(_pl, "supplier_id", None):
  3808. supplier_id_for_pickup = _pl.supplier_id
  3809. delivery_timeline = _build_order_delivery_timeline(None, log_details, order)
  3810. if supplier_id_for_pickup:
  3811. _su = user_crud.get(db, supplier_id_for_pickup)
  3812. if _su:
  3813. supplier_name = _su.real_name
  3814. supplier_phone = _su.phone
  3815. supplier_pickup = _supplier_pickup_address_block(db, supplier_id_for_pickup)
  3816. return success_response({
  3817. "id": order.id,
  3818. "orderNo": order.order_no,
  3819. "regionId": order.region_id,
  3820. "regionName": region_name,
  3821. "regionExpectedDeliveryMinutes": region_expected_delivery_minutes,
  3822. "clientId": order.client_id,
  3823. "clientName": client_name,
  3824. "priceListId": order.price_list_id,
  3825. "priceVersion": order.price_version,
  3826. "status": order.status,
  3827. "orderStatus": order_status,
  3828. "deliveryStatus": delivery_status,
  3829. "payStatus": pay_status,
  3830. "paymentMethod": order_crud.normalize_payment_method(order.payment_method),
  3831. "clientSettlementStatus": getattr(order, "client_settlement_status", None) or "pending",
  3832. "supplierSettlementStatus": getattr(order, "supplier_settlement_status", None) or "pending",
  3833. "clientSettlementAt": str(order.client_settlement_at) if getattr(order, "client_settlement_at", None) else None,
  3834. "supplierSettlementAt": str(order.supplier_settlement_at) if getattr(order, "supplier_settlement_at", None) else None,
  3835. "totalAmount": float(order.total_amount) if order.total_amount else 0,
  3836. "sellingTotalAmount": round(selling_total_amount, 2),
  3837. "supplierTotalAmount": round(supplier_total_amount, 2),
  3838. "paidAmount": float(order.paid_amount) if order.paid_amount else 0,
  3839. "receivableAmount": float(order.receivable_amount) if order.receivable_amount else 0,
  3840. "refundAmount": float(order.refund_amount) if order.refund_amount else 0,
  3841. "contactPerson": order.contact_person,
  3842. "contactPhone": order.contact_phone,
  3843. "deliveryAddress": order.delivery_address,
  3844. "remark": order.remark,
  3845. "cancelReason": order.cancel_reason,
  3846. "cancelReviewNote": order.cancel_review_note,
  3847. "createAt": str(order.create_at) if order.create_at else None,
  3848. "paymentTime": str(order.payment_time) if order.payment_time else None,
  3849. "completedTime": str(order.completed_time) if order.completed_time else None,
  3850. "deliverId": deliver_id_out,
  3851. "deliverName": deliver_name,
  3852. "deliverPhone": deliver_phone,
  3853. "expectedTime": expected_time,
  3854. "deliveryDispatchRemark": delivery_dispatch_remark,
  3855. "deliveryTimeline": delivery_timeline,
  3856. "supplierId": supplier_id_for_pickup,
  3857. "supplierName": supplier_name,
  3858. "supplierPhone": supplier_phone,
  3859. "supplierPickup": {
  3860. "address": supplier_pickup.get("fullAddress"),
  3861. "contactPerson": supplier_pickup.get("contactPerson"),
  3862. "contactPhone": supplier_pickup.get("contactPhone"),
  3863. },
  3864. "deliveryTaskNo": delivery_task_no,
  3865. "deliveryTaskStatus": delivery_task_status,
  3866. "deliveryTaskDelivererStatus": delivery_task_deliverer_status,
  3867. "deliveryTaskSupplierPrepareStatus": delivery_task_supplier_prepare_status,
  3868. "deliveryPickupTime": delivery_pickup_time,
  3869. "deliveryPickupRemark": delivery_pickup_remark,
  3870. "deliveryDeliveryTime": delivery_delivery_time,
  3871. "deliveryDeliveryRemark": delivery_delivery_remark,
  3872. "deliveryPrepareImages": delivery_prepare_images,
  3873. "deliveryPickupImages": delivery_pickup_images,
  3874. "deliveryDeliverImages": delivery_deliver_images,
  3875. "items": item_details,
  3876. "statusLogs": log_details,
  3877. "lockInfo": lock_info
  3878. })
  3879. @router.post("/orders/{order_id}/review-cancel", response_model=dict)
  3880. def review_order_cancel(
  3881. order_id: str,
  3882. request: dict,
  3883. db: Session = Depends(get_db),
  3884. current_user: dict = Depends(require_admin)
  3885. ):
  3886. """审核取消订单"""
  3887. from app.crud.order import order_crud
  3888. order = order_crud.get(db, order_id)
  3889. if not order:
  3890. return error_response(404, "订单不存在")
  3891. lock_err = _ensure_order_write_lock(db, order, current_user.get("user_id"))
  3892. if lock_err is not None:
  3893. return lock_err
  3894. if getattr(order, "cancel_status", "none") != "pending_review":
  3895. return error_response(400, "只有待审核取消的订单才能审核")
  3896. approved = request.get("approved", False)
  3897. review_note = request.get("reviewNote")
  3898. reviewer_id = current_user.get("user_id")
  3899. order = order_crud.review_cancel(
  3900. db,
  3901. order_id,
  3902. approved=approved,
  3903. reviewer_id=reviewer_id,
  3904. review_note=review_note
  3905. )
  3906. return success_response({
  3907. "id": order.id,
  3908. "status": order.status,
  3909. "orderStatus": getattr(order, "order_status", None),
  3910. "deliveryStatus": getattr(order, "delivery_status", None),
  3911. "payStatus": getattr(order, "pay_status", None),
  3912. "cancelStatus": getattr(order, "cancel_status", None),
  3913. "message": "审核完成"
  3914. })
  3915. @router.get("/orders/pending-cancel", response_model=dict)
  3916. def list_pending_cancel_orders(
  3917. page: int = Query(1, ge=1, description="页码"),
  3918. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  3919. db: Session = Depends(get_db),
  3920. current_user: dict = Depends(require_admin)
  3921. ):
  3922. """获取待审核取消的订单列表"""
  3923. from app.crud.order import order_crud, order_item_crud
  3924. orders, total = order_crud.get_multi_with_filters(
  3925. db,
  3926. cancel_status="pending_review",
  3927. skip=(page - 1) * pageSize,
  3928. limit=pageSize
  3929. )
  3930. region_map = {}
  3931. client_map = {}
  3932. result = []
  3933. for order in orders:
  3934. if order.region_id and order.region_id not in region_map:
  3935. region = region_crud.get(db, order.region_id)
  3936. region_map[order.region_id] = region.name if region else None
  3937. if order.client_id and order.client_id not in client_map:
  3938. client = user_crud.get(db, order.client_id)
  3939. client_map[order.client_id] = client.real_name if client else None
  3940. item_count = order_item_crud.get_count_by_order(db, order.id)
  3941. result.append({
  3942. "id": order.id,
  3943. "orderNo": order.order_no,
  3944. "clientId": order.client_id,
  3945. "clientName": client_map.get(order.client_id),
  3946. "regionId": order.region_id,
  3947. "regionName": region_map.get(order.region_id),
  3948. "totalAmount": float(order.total_amount) if order.total_amount else 0,
  3949. "paidAmount": float(order.paid_amount) if order.paid_amount else 0,
  3950. "cancelReason": order.cancel_reason,
  3951. "itemCount": item_count,
  3952. "createAt": str(order.create_at) if order.create_at else None
  3953. })
  3954. return success_response({
  3955. "list": result,
  3956. "total": total,
  3957. "page": page,
  3958. "pageSize": pageSize,
  3959. "totalPages": (total + pageSize - 1) // pageSize
  3960. })
  3961. # =========================================
  3962. # 订单锁定管理 API
  3963. # =========================================
  3964. @router.post("/orders/{order_id}/lock", response_model=dict)
  3965. def lock_order(
  3966. order_id: str,
  3967. db: Session = Depends(get_db),
  3968. current_user: dict = Depends(require_admin)
  3969. ):
  3970. """锁定订单(写操作前由前端显式调用)"""
  3971. from app.crud.order import order_crud
  3972. order = order_crud.get(db, order_id)
  3973. if not order:
  3974. return error_response(404, "订单不存在")
  3975. try:
  3976. order = order_crud.lock_order(
  3977. db,
  3978. order_id=order_id,
  3979. operator_id=current_user.get("user_id")
  3980. )
  3981. return success_response({
  3982. "id": order.id,
  3983. "lockOperatorId": order.lock_operator_id,
  3984. "lockTime": str(order.lock_time) if order.lock_time else None,
  3985. "message": "锁定成功"
  3986. })
  3987. except ValueError as e:
  3988. info = _build_order_lock_info(db, order)
  3989. who = info.get("lockBy") or order.lock_operator_id or "其他操作员"
  3990. when = info.get("lockTime") or "未知"
  3991. return error_response(400, f"订单已被 {who} 于 {when} 锁定")
  3992. @router.post("/orders/{order_id}/unlock", response_model=dict)
  3993. def unlock_order(
  3994. order_id: str,
  3995. request: dict = Body(default={}),
  3996. db: Session = Depends(get_db),
  3997. current_user: dict = Depends(require_admin)
  3998. ):
  3999. """解锁订单;force=true 时管理员可强制解除他人锁"""
  4000. from app.crud.order import order_crud
  4001. order = order_crud.get(db, order_id)
  4002. if not order:
  4003. return error_response(404, "订单不存在")
  4004. body = request or {}
  4005. force = bool(body.get("force"))
  4006. role = current_user.get("role")
  4007. if force and role not in ("admin", "super_admin"):
  4008. return error_response(403, "无权限强制解锁")
  4009. try:
  4010. order = order_crud.unlock_order(
  4011. db,
  4012. order_id=order_id,
  4013. operator_id=current_user.get("user_id"),
  4014. force=force,
  4015. )
  4016. return success_response({
  4017. "id": order.id,
  4018. "message": "解锁成功"
  4019. })
  4020. except ValueError as e:
  4021. return error_response(400, str(e))
  4022. # =========================================
  4023. # 订单调整管理 API
  4024. # =========================================
  4025. @router.post("/orders/{order_id}/adjust", response_model=dict)
  4026. def adjust_order(
  4027. order_id: str,
  4028. request: dict,
  4029. db: Session = Depends(get_db),
  4030. current_user: dict = Depends(require_admin)
  4031. ):
  4032. """订单调整(增量/减量/删除/替代)"""
  4033. import json
  4034. from app.crud.order import order_crud, order_item_crud, order_adjust_crud
  4035. from decimal import Decimal
  4036. from app.models.models import BizDeliveryTask, BizDeliveryTaskItem
  4037. order = order_crud.get(db, order_id)
  4038. if not order:
  4039. return error_response(404, "订单不存在")
  4040. # 写操作占锁(含 TTL;他人未过期锁则拒绝)
  4041. operator_id = current_user.get("user_id")
  4042. lock_err = _ensure_order_write_lock(db, order, operator_id)
  4043. if lock_err is not None:
  4044. return lock_err
  4045. # 仅线下支付订单允许运营调整
  4046. if getattr(order, "payment_method", None) != "offline":
  4047. return error_response(400, "仅线下支付订单支持运营调整")
  4048. # 新状态模型:仅未完成/未取消的线下单支持调整
  4049. if getattr(order, "order_status", None) not in ["pending_payment", "pending_confirm"]:
  4050. return error_response(400, "当前状态不支持调整")
  4051. adjustments = request.get("adjustments", [])
  4052. address_update = request.get("addressUpdate") or {}
  4053. has_address_update = isinstance(address_update, dict) and len(address_update.keys()) > 0
  4054. if not adjustments and not has_address_update:
  4055. return error_response(400, "请提供调整项或地址调整信息")
  4056. operator_name = current_user.get("real_name")
  4057. adjust_results = []
  4058. for adj in adjustments:
  4059. order_item_id = adj.get("orderItemId")
  4060. adjust_type = adj.get("adjustType") # increase/reduce/delete/substitute
  4061. new_quantity = adj.get("newQuantity")
  4062. new_product_id = adj.get("newProductId")
  4063. reason = adj.get("reason")
  4064. if not order_item_id or not adjust_type:
  4065. continue
  4066. if not reason:
  4067. return error_response(400, "调整原因不能为空")
  4068. # 获取订单项
  4069. item = order_item_crud.get(db, order_item_id)
  4070. if not item or item.order_id != order_id:
  4071. continue
  4072. before_quantity = item.quantity
  4073. before_amount = item.amount
  4074. try:
  4075. if adjust_type == "increase":
  4076. # 增量
  4077. if new_quantity is None or new_quantity < 0:
  4078. return error_response(400, "增量后数量不能小于0")
  4079. if new_quantity < float(item.quantity):
  4080. return error_response(400, "增量后数量不能小于原数量")
  4081. new_amount = Decimal(str(new_quantity)) * item.unit_price
  4082. order_item_crud.update_item_adjustment(
  4083. db, order_item_id,
  4084. new_quantity=Decimal(str(new_quantity)),
  4085. new_amount=new_amount,
  4086. new_status="increased",
  4087. note=reason,
  4088. updater_id=operator_id,
  4089. updater_name=operator_name
  4090. )
  4091. snapshot_items = order_item_crud.get_by_order(db, order_id)
  4092. snapshot = [{
  4093. "orderItemId": it.id,
  4094. "productId": it.product_id,
  4095. "quantity": float(it.quantity) if it.quantity else 0,
  4096. "status": it.status,
  4097. } for it in snapshot_items if it.is_del == 0]
  4098. order_adjust_crud.create_adjust_record(
  4099. db,
  4100. order_id=order_id,
  4101. order_item_id=order_item_id,
  4102. adjust_type="increase",
  4103. operator_id=operator_id,
  4104. operator_name=operator_name,
  4105. before_quantity=before_quantity,
  4106. after_quantity=Decimal(str(new_quantity)),
  4107. before_amount=before_amount,
  4108. after_amount=new_amount,
  4109. difference_amount=new_amount - before_amount,
  4110. reason=reason,
  4111. snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
  4112. )
  4113. adjust_results.append({
  4114. "orderItemId": order_item_id,
  4115. "adjustType": "increase",
  4116. "success": True
  4117. })
  4118. elif adjust_type == "reduce":
  4119. # 减量
  4120. if new_quantity is None or new_quantity < 0:
  4121. return error_response(400, "减量后数量不能小于0")
  4122. if new_quantity > float(item.quantity):
  4123. return error_response(400, "减量后数量不能大于原数量")
  4124. new_amount = Decimal(str(new_quantity)) * item.unit_price
  4125. order_item_crud.mark_as_reduced(
  4126. db, order_item_id,
  4127. new_quantity=Decimal(str(new_quantity)),
  4128. new_amount=new_amount,
  4129. note=reason
  4130. )
  4131. snapshot_items = order_item_crud.get_by_order(db, order_id)
  4132. snapshot = [{
  4133. "orderItemId": it.id,
  4134. "productId": it.product_id,
  4135. "quantity": float(it.quantity) if it.quantity else 0,
  4136. "status": it.status,
  4137. } for it in snapshot_items if it.is_del == 0]
  4138. # 创建调整记录
  4139. order_adjust_crud.create_adjust_record(
  4140. db,
  4141. order_id=order_id,
  4142. order_item_id=order_item_id,
  4143. adjust_type="reduce",
  4144. operator_id=operator_id,
  4145. operator_name=operator_name,
  4146. before_quantity=before_quantity,
  4147. after_quantity=Decimal(str(new_quantity)),
  4148. before_amount=before_amount,
  4149. after_amount=new_amount,
  4150. difference_amount=new_amount - before_amount,
  4151. reason=reason,
  4152. snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
  4153. )
  4154. adjust_results.append({
  4155. "orderItemId": order_item_id,
  4156. "adjustType": "reduce",
  4157. "success": True
  4158. })
  4159. elif adjust_type == "delete":
  4160. # 删除
  4161. order_item_crud.mark_as_deleted(
  4162. db, order_item_id,
  4163. note=reason
  4164. )
  4165. snapshot_items = order_item_crud.get_by_order(db, order_id)
  4166. snapshot = [{
  4167. "orderItemId": it.id,
  4168. "productId": it.product_id,
  4169. "quantity": float(it.quantity) if it.quantity else 0,
  4170. "status": it.status,
  4171. } for it in snapshot_items if it.is_del == 0]
  4172. # 创建调整记录
  4173. order_adjust_crud.create_adjust_record(
  4174. db,
  4175. order_id=order_id,
  4176. order_item_id=order_item_id,
  4177. adjust_type="delete",
  4178. operator_id=operator_id,
  4179. operator_name=operator_name,
  4180. before_quantity=before_quantity,
  4181. after_quantity=Decimal("0"),
  4182. before_amount=before_amount,
  4183. after_amount=Decimal("0"),
  4184. difference_amount=-before_amount,
  4185. reason=reason,
  4186. snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
  4187. )
  4188. adjust_results.append({
  4189. "orderItemId": order_item_id,
  4190. "adjustType": "delete",
  4191. "success": True
  4192. })
  4193. elif adjust_type == "substitute":
  4194. # 替代
  4195. if not new_product_id or not new_quantity:
  4196. return error_response(400, "替代商品ID和数量不能为空")
  4197. # 获取新商品信息
  4198. new_product = product_crud.get(db, new_product_id)
  4199. if not new_product:
  4200. return error_response(404, "替代商品不存在")
  4201. # 需要获取新商品的价格
  4202. from app.crud.price_list import price_list_item_crud
  4203. price_item = price_list_item_crud.get_by_product_and_price_list(
  4204. db, new_product_id, order.price_list_id
  4205. )
  4206. if not price_item:
  4207. return error_response(400, "替代商品不在价格清单中")
  4208. new_unit_price = price_item.price
  4209. new_amount = Decimal(str(new_quantity)) * new_unit_price
  4210. order_item_crud.update_item_adjustment(
  4211. db, order_item_id,
  4212. new_quantity=Decimal(str(new_quantity)),
  4213. new_amount=new_amount,
  4214. new_status="substituted",
  4215. note=f"{reason},替代商品: {new_product.name}",
  4216. updater_id=operator_id,
  4217. updater_name=operator_name
  4218. )
  4219. snapshot_items = order_item_crud.get_by_order(db, order_id)
  4220. snapshot = [{
  4221. "orderItemId": it.id,
  4222. "productId": it.product_id,
  4223. "quantity": float(it.quantity) if it.quantity else 0,
  4224. "status": it.status,
  4225. } for it in snapshot_items if it.is_del == 0]
  4226. # 创建调整记录
  4227. order_adjust_crud.create_adjust_record(
  4228. db,
  4229. order_id=order_id,
  4230. order_item_id=order_item_id,
  4231. adjust_type="substitute",
  4232. operator_id=operator_id,
  4233. operator_name=operator_name,
  4234. before_quantity=before_quantity,
  4235. after_quantity=Decimal(str(new_quantity)),
  4236. before_amount=before_amount,
  4237. after_amount=new_amount,
  4238. difference_amount=new_amount - before_amount,
  4239. reason=reason,
  4240. substitute_product_id=new_product_id,
  4241. snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
  4242. )
  4243. adjust_results.append({
  4244. "orderItemId": order_item_id,
  4245. "adjustType": "substitute",
  4246. "newProductId": new_product_id,
  4247. "success": True
  4248. })
  4249. except Exception as e:
  4250. adjust_results.append({
  4251. "orderItemId": order_item_id,
  4252. "adjustType": adjust_type,
  4253. "success": False,
  4254. "error": str(e)
  4255. })
  4256. if has_address_update:
  4257. old_delivery_address = order.delivery_address or ""
  4258. old_contact_person = order.contact_person or ""
  4259. old_contact_phone = order.contact_phone or ""
  4260. new_delivery_address = str(address_update.get("deliveryAddress") or old_delivery_address).strip()
  4261. new_contact_person = str(address_update.get("contactPerson") or old_contact_person).strip()
  4262. new_contact_phone = str(address_update.get("contactPhone") or old_contact_phone).strip()
  4263. address_reason = str(address_update.get("reason") or "").strip()
  4264. address_changed = (
  4265. new_delivery_address != old_delivery_address
  4266. or new_contact_person != old_contact_person
  4267. or new_contact_phone != old_contact_phone
  4268. )
  4269. if address_changed:
  4270. order.delivery_address = new_delivery_address
  4271. order.contact_person = new_contact_person
  4272. order.contact_phone = new_contact_phone
  4273. active_task = db.query(BizDeliveryTask).filter(
  4274. BizDeliveryTask.order_id == order_id,
  4275. BizDeliveryTask.is_del == 0
  4276. ).first()
  4277. if active_task:
  4278. task_items = db.query(BizDeliveryTaskItem).filter(
  4279. BizDeliveryTaskItem.task_id == active_task.id,
  4280. BizDeliveryTaskItem.is_del == 0
  4281. ).all()
  4282. for task_item in task_items:
  4283. task_item.delivery_address = new_delivery_address
  4284. task_item.contact_person = new_contact_person
  4285. task_item.contact_phone = new_contact_phone
  4286. task_item.update_at = datetime.now()
  4287. snapshot_items = order_item_crud.get_by_order(db, order_id)
  4288. snapshot = [{
  4289. "orderItemId": it.id,
  4290. "productId": it.product_id,
  4291. "quantity": float(it.quantity) if it.quantity else 0,
  4292. "status": it.status,
  4293. } for it in snapshot_items if it.is_del == 0]
  4294. reason_parts = []
  4295. reason_parts.append(f"地址:{old_delivery_address or '-'} -> {new_delivery_address or '-'}")
  4296. reason_parts.append(f"联系人:{old_contact_person or '-'} -> {new_contact_person or '-'}")
  4297. reason_parts.append(f"电话:{old_contact_phone or '-'} -> {new_contact_phone or '-'}")
  4298. if address_reason:
  4299. reason_parts.append(f"备注:{address_reason}")
  4300. address_log_reason = ";".join(reason_parts)
  4301. order_adjust_crud.create_adjust_record(
  4302. db,
  4303. order_id=order_id,
  4304. order_item_id=None,
  4305. adjust_type="address",
  4306. operator_id=operator_id,
  4307. operator_name=operator_name,
  4308. before_quantity=Decimal("0"),
  4309. after_quantity=Decimal("0"),
  4310. before_amount=Decimal("0"),
  4311. after_amount=Decimal("0"),
  4312. difference_amount=Decimal("0"),
  4313. reason=address_log_reason,
  4314. snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
  4315. )
  4316. adjust_results.append({
  4317. "adjustType": "address",
  4318. "success": True
  4319. })
  4320. # 重新计算订单应收金额
  4321. all_items = order_item_crud.get_by_order(db, order_id)
  4322. new_total = sum(
  4323. float(item.amount) for item in all_items
  4324. if item.status != "deleted" and item.is_del == 0
  4325. )
  4326. new_receivable = Decimal(str(new_total))
  4327. order.total_amount = new_receivable
  4328. order.receivable_amount = new_receivable
  4329. order.update_at = datetime.now()
  4330. db.commit()
  4331. db.refresh(order)
  4332. return success_response({
  4333. "id": order.id,
  4334. "totalAmount": float(order.total_amount),
  4335. "receivableAmount": float(order.receivable_amount),
  4336. "adjustments": adjust_results,
  4337. "message": "调整成功"
  4338. })
  4339. @router.get("/orders/{order_id}/adjusts", response_model=dict)
  4340. def list_order_adjusts(
  4341. order_id: str,
  4342. db: Session = Depends(get_db),
  4343. current_user: dict = Depends(require_admin)
  4344. ):
  4345. """获取订单调整记录"""
  4346. import json
  4347. from app.crud.order import order_crud, order_adjust_crud
  4348. order = order_crud.get(db, order_id)
  4349. if not order:
  4350. return error_response(404, "订单不存在")
  4351. adjusts = order_adjust_crud.get_by_order(db, order_id)
  4352. result = []
  4353. for adj in adjusts:
  4354. result.append({
  4355. "id": adj.id,
  4356. "orderId": adj.order_id,
  4357. "orderItemId": adj.order_item_id,
  4358. "adjustType": adj.adjust_type,
  4359. "beforeQuantity": float(adj.before_quantity) if adj.before_quantity else 0,
  4360. "afterQuantity": float(adj.after_quantity) if adj.after_quantity else 0,
  4361. "beforeAmount": float(adj.before_amount) if adj.before_amount else 0,
  4362. "afterAmount": float(adj.after_amount) if adj.after_amount else 0,
  4363. "differenceAmount": float(adj.difference_amount) if adj.difference_amount else 0,
  4364. "reason": adj.reason,
  4365. "snapshotItems": json.loads(adj.snapshot_items_json) if getattr(adj, "snapshot_items_json", None) else [],
  4366. "operatorName": adj.operator_name,
  4367. "createAt": str(adj.create_at) if adj.create_at else None
  4368. })
  4369. return success_response({
  4370. "list": result,
  4371. "total": len(result)
  4372. })
  4373. @router.post("/orders/{order_id}/force-complete", response_model=dict)
  4374. def force_complete_order(
  4375. order_id: str,
  4376. request: dict,
  4377. db: Session = Depends(get_db),
  4378. current_user: dict = Depends(require_admin)
  4379. ):
  4380. """运营强制完结订单"""
  4381. from app.crud.order import order_crud
  4382. order = order_crud.get(db, order_id)
  4383. if not order:
  4384. return error_response(404, "订单不存在")
  4385. operator_id = current_user.get("user_id")
  4386. lock_err = _ensure_order_write_lock(db, order, operator_id)
  4387. if lock_err is not None:
  4388. return lock_err
  4389. reason = request.get("reason", "运营强制完结")
  4390. try:
  4391. order = order_crud.force_complete(
  4392. db,
  4393. order_id=order_id,
  4394. operator_id=current_user.get("user_id"),
  4395. operator_role="admin",
  4396. reason=reason
  4397. )
  4398. return success_response({
  4399. "id": order.id,
  4400. "status": order.status,
  4401. "orderStatus": getattr(order, "order_status", None) or "pending_confirm",
  4402. "deliveryStatus": getattr(order, "delivery_status", None) or "pending_dispatch",
  4403. "payStatus": getattr(order, "pay_status", None) or "pending_payment",
  4404. "completedTime": str(order.completed_time) if order.completed_time else None,
  4405. "message": "强制完结成功"
  4406. })
  4407. except ValueError as e:
  4408. return error_response(400, str(e))
  4409. @router.post("/orders/{order_id}/partial-delivered", response_model=dict)
  4410. def mark_partial_delivered(
  4411. order_id: str,
  4412. request: dict,
  4413. db: Session = Depends(get_db),
  4414. current_user: dict = Depends(require_admin)
  4415. ):
  4416. """已废弃:旧的部分送达流程不再支持。"""
  4417. return error_response(410, "该接口已下线,请使用配送任务状态流转接口")
  4418. @router.post("/orders/{order_id}/confirm-partial", response_model=dict)
  4419. def confirm_partial_delivery(
  4420. order_id: str,
  4421. request: dict,
  4422. db: Session = Depends(get_db),
  4423. current_user: dict = Depends(require_admin)
  4424. ):
  4425. """已废弃:旧的部分送达确认流程不再支持。"""
  4426. return error_response(410, "该接口已下线,请使用订单确认收货流程")
  4427. @router.post("/orders/{order_id}/create-delivery-task", response_model=dict)
  4428. def create_delivery_task_from_order(
  4429. order_id: str,
  4430. db: Session = Depends(get_db),
  4431. current_user: dict = Depends(require_admin)
  4432. ):
  4433. """从订单创建配送任务"""
  4434. from app.crud.order import order_crud, order_item_crud
  4435. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  4436. order = order_crud.get(db, order_id)
  4437. if not order:
  4438. return error_response(404, "订单不存在")
  4439. # 只有待分配配送状态可以创建配送任务
  4440. if getattr(order, "delivery_status", None) != "pending_dispatch":
  4441. return error_response(400, "只有待分配配送状态的订单可以创建配送任务")
  4442. # 检查是否已存在配送任务
  4443. existing_task = delivery_task_crud.get_by_order(db, order_id)
  4444. if existing_task:
  4445. return error_response(400, "该订单已存在配送任务")
  4446. # 获取订单关联的价格清单,以获取供应商ID
  4447. from app.crud.price_list import price_list_crud
  4448. price_list = price_list_crud.get(db, order.price_list_id)
  4449. if not price_list:
  4450. return error_response(400, "订单关联的价格清单不存在")
  4451. supplier_id = price_list.supplier_id
  4452. # 获取订单项
  4453. order_items = order_item_crud.get_by_order(db, order_id)
  4454. # 构建配送任务明细数据
  4455. task_items_data = []
  4456. for item in order_items:
  4457. if item.status == "deleted" or item.is_del != 0:
  4458. continue
  4459. task_items_data.append({
  4460. "order_item_id": item.id,
  4461. "product_id": item.product_id,
  4462. "client_id": order.client_id,
  4463. "delivery_address": order.delivery_address,
  4464. "contact_person": order.contact_person,
  4465. "contact_phone": order.contact_phone,
  4466. "quantity": float(item.quantity) if item.quantity else 0
  4467. })
  4468. creator = {
  4469. "user_id": current_user.get("user_id"),
  4470. "real_name": current_user.get("real_name")
  4471. }
  4472. # 创建配送任务及明细
  4473. task, task_items = delivery_task_crud.create_delivery_task_from_order(
  4474. db,
  4475. order_id=order_id,
  4476. order_items=task_items_data,
  4477. supplier_id=supplier_id,
  4478. region_id=order.region_id,
  4479. creator=creator
  4480. )
  4481. # 阶段D收尾:优先维护三字段,再回写旧 status
  4482. from app.core.order_status_compat import derive_legacy_status_from_three
  4483. order.order_status = "pending_confirm"
  4484. order.delivery_status = "delivering"
  4485. order.status = derive_legacy_status_from_three(
  4486. order_status=order.order_status,
  4487. delivery_status=order.delivery_status,
  4488. pay_status=order.pay_status,
  4489. )
  4490. order.delivery_assign_time = datetime.now()
  4491. order.update_at = datetime.now()
  4492. db.commit()
  4493. return success_response({
  4494. "id": task.id,
  4495. "taskNo": task.task_no,
  4496. "status": task.status,
  4497. "effectiveTime": str(task.effective_time) if task.effective_time else None,
  4498. "message": "配送任务创建成功"
  4499. })
  4500. @router.post("/orders/{order_id}/assign-deliverer", response_model=dict)
  4501. def assign_deliverer_to_order(
  4502. order_id: str,
  4503. request: dict,
  4504. db: Session = Depends(get_db),
  4505. current_user: dict = Depends(require_admin)
  4506. ):
  4507. """分配配送员并创建配送任务(一步完成)"""
  4508. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  4509. if req_err is not None:
  4510. return req_err
  4511. from app.crud.order import order_crud, order_item_crud
  4512. from app.crud.delivery import delivery_task_crud
  4513. order = order_crud.get(db, order_id)
  4514. if not order:
  4515. return error_response(404, "订单不存在")
  4516. # 写操作占锁(含 TTL)
  4517. operator_id = current_user.get("user_id")
  4518. lock_err = _ensure_order_write_lock(db, order, operator_id)
  4519. if lock_err is not None:
  4520. return lock_err
  4521. deliver_id = request.get("deliverId")
  4522. if not deliver_id:
  4523. return error_response(400, "请选择配送员")
  4524. deadline_raw = request.get("deliveryDeadlineAt") or request.get("delivery_deadline_at")
  4525. if not deadline_raw:
  4526. return error_response(400, "请选择配送时效(截止时间)")
  4527. dispatch_remark_raw = request.get("dispatchRemark") or request.get("dispatch_remark") or ""
  4528. dispatch_remark = str(dispatch_remark_raw).strip()[:500] if dispatch_remark_raw else None
  4529. effective_deadline = None
  4530. try:
  4531. ds = str(deadline_raw).strip().replace("Z", "+00:00")
  4532. effective_deadline = datetime.fromisoformat(ds)
  4533. if effective_deadline.tzinfo is not None:
  4534. effective_deadline = effective_deadline.replace(tzinfo=None)
  4535. except (TypeError, ValueError):
  4536. effective_deadline = None
  4537. if not effective_deadline:
  4538. return error_response(400, "配送时效格式不正确")
  4539. # 验证配送员
  4540. deliver = user_crud.get(db, deliver_id)
  4541. if not deliver:
  4542. return error_response(404, "配送员不存在")
  4543. if deliver.role != "deliver":
  4544. return error_response(400, "该用户不是配送员")
  4545. if not deliver.region_id or deliver.region_id != order.region_id:
  4546. return error_response(400, "只能选择订单所属区域的配送员")
  4547. prev_delivery_status = getattr(order, "delivery_status", None) or "pending_dispatch"
  4548. operator_name = (current_user.get("real_name") or "").strip() or None
  4549. updater = {"user_id": operator_id, "real_name": operator_name}
  4550. # 检查是否已存在配送任务
  4551. existing_task = delivery_task_crud.get_by_order(db, order_id)
  4552. old_deliver_id = existing_task.deliver_id if existing_task else None
  4553. if existing_task:
  4554. task = delivery_task_crud.update_with_history(
  4555. db,
  4556. existing_task.id,
  4557. {
  4558. "deliver_id": deliver_id,
  4559. "status": "assigned",
  4560. "effective_time": effective_deadline,
  4561. "dispatch_remark": dispatch_remark,
  4562. },
  4563. updater=updater,
  4564. )
  4565. if not task:
  4566. return error_response(400, "更新配送任务失败")
  4567. else:
  4568. # 获取供应商ID
  4569. from app.crud.price_list import price_list_crud
  4570. price_list = price_list_crud.get(db, order.price_list_id)
  4571. if not price_list:
  4572. return error_response(400, "订单关联的价格清单不存在")
  4573. # 获取订单项
  4574. order_items = order_item_crud.get_by_order(db, order_id)
  4575. # 构建配送任务明细数据
  4576. task_items_data = []
  4577. for item in order_items:
  4578. if item.status == "deleted" or item.is_del != 0:
  4579. continue
  4580. task_items_data.append({
  4581. "order_item_id": item.id,
  4582. "product_id": item.product_id,
  4583. "client_id": order.client_id,
  4584. "delivery_address": order.delivery_address,
  4585. "contact_person": order.contact_person,
  4586. "contact_phone": order.contact_phone,
  4587. "quantity": float(item.quantity) if item.quantity else 0
  4588. })
  4589. creator = {
  4590. "user_id": current_user.get("user_id"),
  4591. "real_name": current_user.get("real_name")
  4592. }
  4593. # 创建配送任务
  4594. task, _ = delivery_task_crud.create_delivery_task_from_order(
  4595. db,
  4596. order_id=order_id,
  4597. order_items=task_items_data,
  4598. supplier_id=price_list.supplier_id,
  4599. region_id=order.region_id,
  4600. creator=creator
  4601. )
  4602. # 分配配送员
  4603. task.deliver_id = deliver_id
  4604. task.status = "assigned"
  4605. task.effective_time = effective_deadline
  4606. task.dispatch_remark = dispatch_remark
  4607. task.update_at = datetime.now()
  4608. # 分配配送员后同步双状态;首次分配时供应商从待备货起算,更换配送员不重置备货进度
  4609. if task and task.deliver_id:
  4610. task.deliverer_status = "pending_pickup"
  4611. if not old_deliver_id:
  4612. task.supplier_prepare_status = "pending_prepare"
  4613. delivery_task_crud._sync_legacy_status_from_dual(task)
  4614. # 阶段D收尾:优先维护三字段,再回写旧 status
  4615. from app.core.order_status_compat import derive_legacy_status_from_three
  4616. order.order_status = "pending_confirm"
  4617. if not order.delivery_status or order.delivery_status == "pending_dispatch":
  4618. order.delivery_status = "delivering"
  4619. order.status = derive_legacy_status_from_three(
  4620. order_status=order.order_status,
  4621. delivery_status=order.delivery_status,
  4622. pay_status=order.pay_status,
  4623. )
  4624. order.delivery_assign_time = datetime.now()
  4625. order.update_at = datetime.now()
  4626. def _deliver_display_name(uid: Optional[str]) -> Optional[str]:
  4627. if not uid:
  4628. return None
  4629. u = user_crud.get(db, uid)
  4630. return (u.real_name if u else None) or uid
  4631. if not existing_task:
  4632. order_crud._create_status_log(
  4633. db,
  4634. order_id,
  4635. "",
  4636. "delivery.assign",
  4637. operator_id,
  4638. "admin",
  4639. reason=json.dumps(
  4640. {
  4641. "fromDeliverId": None,
  4642. "toDeliverId": deliver_id,
  4643. "fromDeliverName": None,
  4644. "toDeliverName": deliver.real_name,
  4645. },
  4646. ensure_ascii=False,
  4647. ),
  4648. create_name=operator_name,
  4649. )
  4650. elif old_deliver_id and old_deliver_id != deliver_id:
  4651. order_crud._create_status_log(
  4652. db,
  4653. order_id,
  4654. "",
  4655. "delivery.reassign",
  4656. operator_id,
  4657. "admin",
  4658. reason=json.dumps(
  4659. {
  4660. "fromDeliverId": old_deliver_id,
  4661. "toDeliverId": deliver_id,
  4662. "fromDeliverName": _deliver_display_name(old_deliver_id),
  4663. "toDeliverName": deliver.real_name,
  4664. },
  4665. ensure_ascii=False,
  4666. ),
  4667. create_name=operator_name,
  4668. )
  4669. elif not old_deliver_id and deliver_id:
  4670. order_crud._create_status_log(
  4671. db,
  4672. order_id,
  4673. "",
  4674. "delivery.assign",
  4675. operator_id,
  4676. "admin",
  4677. reason=json.dumps(
  4678. {
  4679. "fromDeliverId": None,
  4680. "toDeliverId": deliver_id,
  4681. "fromDeliverName": None,
  4682. "toDeliverName": deliver.real_name,
  4683. },
  4684. ensure_ascii=False,
  4685. ),
  4686. create_name=operator_name,
  4687. )
  4688. if prev_delivery_status != order.delivery_status:
  4689. order_crud._create_status_log(
  4690. db,
  4691. order_id,
  4692. f"ds_{prev_delivery_status}",
  4693. f"ds_{order.delivery_status}",
  4694. operator_id,
  4695. "admin",
  4696. create_name=operator_name,
  4697. )
  4698. db.commit()
  4699. db.refresh(task)
  4700. return success_response({
  4701. "id": task.id,
  4702. "taskNo": task.task_no,
  4703. "deliverId": task.deliver_id,
  4704. "deliverName": deliver.real_name,
  4705. "status": task.status,
  4706. "effectiveTime": str(task.effective_time) if task.effective_time else None,
  4707. "message": "配送员分配成功"
  4708. })
  4709. @router.post("/orders/{order_id}/assign-delivery", response_model=dict)
  4710. def assign_delivery_alias(
  4711. order_id: str,
  4712. request: dict,
  4713. db: Session = Depends(get_db),
  4714. current_user: dict = Depends(require_admin)
  4715. ):
  4716. """兼容旧路径:与 assign-deliverer 相同"""
  4717. return assign_deliverer_to_order(order_id, request, db, current_user)
  4718. # =========================================
  4719. # 结算管理 API (Web端)
  4720. # =========================================
  4721. def _build_settlement_no(prefix: str) -> str:
  4722. return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S')}{str(uuid.uuid4().hex)[:6].upper()}"
  4723. def _parse_datetime_range(start_date: Optional[str], end_date: Optional[str]):
  4724. dt_start = None
  4725. dt_end = None
  4726. if start_date:
  4727. try:
  4728. dt_start = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
  4729. except Exception:
  4730. dt_start = None
  4731. if end_date:
  4732. try:
  4733. dt_end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
  4734. except Exception:
  4735. dt_end = None
  4736. return dt_start, dt_end
  4737. def _supplier_unit_yuan_from_pli(pli: Optional[BizPriceListItem]) -> Optional[float]:
  4738. """价格清单行 price 为面向客户的最终单价;pricing_coefficient 为调价系数(%),供货单价 = price / (1 + 系数/100)。"""
  4739. if pli is None or getattr(pli, "price", None) is None:
  4740. return None
  4741. final_price = Decimal(str(pli.price))
  4742. raw_coeff = getattr(pli, "pricing_coefficient", None)
  4743. if raw_coeff is None:
  4744. coeff_pct = Decimal("0")
  4745. else:
  4746. try:
  4747. coeff_pct = Decimal(str(raw_coeff))
  4748. except Exception:
  4749. coeff_pct = Decimal("0")
  4750. factor = Decimal("1") + coeff_pct / Decimal("100")
  4751. if factor <= Decimal("0"):
  4752. factor = Decimal("1")
  4753. supplier_unit = (final_price / factor).quantize(Decimal("0.01"))
  4754. return float(supplier_unit)
  4755. def _price_list_item_map(db: Session, price_list_ids: List[str]) -> dict:
  4756. """(price_list_id, product_id, unit_id) -> BizPriceListItem"""
  4757. if not price_list_ids:
  4758. return {}
  4759. rows = (
  4760. db.query(BizPriceListItem)
  4761. .filter(BizPriceListItem.price_list_id.in_(price_list_ids), BizPriceListItem.is_del == 0)
  4762. .all()
  4763. )
  4764. return {(r.price_list_id, r.product_id, r.unit_id): r for r in rows}
  4765. def _batch_supplier_totals_for_orders(db: Session, order_ids: List[str]) -> dict:
  4766. """订单维度供货价合计(元),用于供应商结算。"""
  4767. if not order_ids:
  4768. return {}
  4769. orders = db.query(BizOrder).filter(BizOrder.id.in_(order_ids), BizOrder.is_del == 0).all()
  4770. om = {o.id: o for o in orders}
  4771. lines = (
  4772. db.query(BizOrderItem)
  4773. .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
  4774. .all()
  4775. )
  4776. by_oid: dict = {}
  4777. for ln in lines:
  4778. by_oid.setdefault(ln.order_id, []).append(ln)
  4779. pl_ids = list({om[oid].price_list_id for oid in order_ids if oid in om and om[oid].price_list_id})
  4780. pli_map = _price_list_item_map(db, pl_ids)
  4781. out: dict = {}
  4782. for oid in order_ids:
  4783. o = om.get(oid)
  4784. if not o:
  4785. out[oid] = 0.0
  4786. continue
  4787. tot = Decimal("0")
  4788. for ln in by_oid.get(oid, []):
  4789. pli = pli_map.get((o.price_list_id, ln.product_id, ln.unit_id))
  4790. sup_u = _supplier_unit_yuan_from_pli(pli)
  4791. if sup_u is None:
  4792. sup_u = float(ln.unit_price or 0)
  4793. qty = Decimal(str(ln.quantity or 0))
  4794. tot += qty * Decimal(str(sup_u))
  4795. out[oid] = float(tot.quantize(Decimal("0.01")))
  4796. return out
  4797. def _build_order_line_children_for_settlement_list(
  4798. db: Session,
  4799. order: BizOrder,
  4800. lines: List[BizOrderItem],
  4801. pli_map: dict,
  4802. product_map: dict,
  4803. unit_map: dict,
  4804. supplier_id: Optional[str] = None,
  4805. supplier_name: Optional[str] = None,
  4806. ) -> List[dict]:
  4807. children: List[dict] = []
  4808. for ln in lines:
  4809. p = product_map.get(ln.product_id)
  4810. u = unit_map.get(ln.unit_id)
  4811. pli = pli_map.get((order.price_list_id, ln.product_id, ln.unit_id))
  4812. sup_u = _supplier_unit_yuan_from_pli(pli)
  4813. if sup_u is None:
  4814. sup_u = float(ln.unit_price or 0)
  4815. sell_u = float(ln.unit_price or 0)
  4816. qty = float(ln.quantity or 0)
  4817. sell_amt = float(ln.amount or 0) if ln.amount is not None else round(qty * sell_u, 2)
  4818. sup_amt = round(qty * sup_u, 2)
  4819. img = None
  4820. if p and getattr(p, "image_url", None):
  4821. img = str(p.image_url).strip() or None
  4822. mv = None
  4823. if p is not None and p.measure_value is not None:
  4824. try:
  4825. mv = float(p.measure_value)
  4826. except (TypeError, ValueError):
  4827. mv = None
  4828. children.append({
  4829. "rowType": "line",
  4830. "id": f"line-{ln.id}",
  4831. "lineId": ln.id,
  4832. "productId": ln.product_id,
  4833. "productName": p.name if p else None,
  4834. "skuCode": p.sku_code if p else None,
  4835. "imageUrl": img,
  4836. "unitName": u.name if u else None,
  4837. "measureValue": mv,
  4838. "spec": getattr(p, "spec", None) if p else None,
  4839. "quantity": qty,
  4840. "sellingUnitPrice": sell_u,
  4841. "sellingAmount": sell_amt,
  4842. "supplierUnitPrice": sup_u,
  4843. "supplierAmount": sup_amt,
  4844. "supplierId": supplier_id,
  4845. "supplierName": supplier_name,
  4846. })
  4847. return children
  4848. def _build_client_pending_settlement_tree_rows(
  4849. db: Session,
  4850. orders: List[BizOrder],
  4851. client_map: dict,
  4852. ) -> List[dict]:
  4853. if not orders:
  4854. return []
  4855. # 避免模块级循环依赖:在需要处局部导入
  4856. from app.crud.order import order_crud
  4857. order_ids = [o.id for o in orders]
  4858. region_ids = list({o.region_id for o in orders if o.region_id})
  4859. region_map: dict = {}
  4860. for rid in region_ids:
  4861. region = region_crud.get(db, rid)
  4862. region_map[rid] = region.name if region else None
  4863. lines = (
  4864. db.query(BizOrderItem)
  4865. .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
  4866. .order_by(BizOrderItem.order_id.asc(), BizOrderItem.create_at.asc())
  4867. .all()
  4868. )
  4869. by_oid: dict = {}
  4870. for ln in lines:
  4871. by_oid.setdefault(ln.order_id, []).append(ln)
  4872. product_ids = list({ln.product_id for ln in lines})
  4873. unit_ids = list({ln.unit_id for ln in lines})
  4874. products = (
  4875. db.query(BasProduct).filter(BasProduct.id.in_(product_ids), BasProduct.is_del == 0).all()
  4876. ) if product_ids else []
  4877. product_map = {p.id: p for p in products}
  4878. units = (
  4879. db.query(BasProductUnit).filter(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0).all()
  4880. ) if unit_ids else []
  4881. unit_map = {u.id: u for u in units}
  4882. pl_ids = list({o.price_list_id for o in orders if o.price_list_id})
  4883. pli_map = _price_list_item_map(db, pl_ids)
  4884. price_lists = (
  4885. db.query(BizPriceList).filter(BizPriceList.id.in_(pl_ids), BizPriceList.is_del == 0).all()
  4886. ) if pl_ids else []
  4887. supplier_id_by_price_list = {p.id: p.supplier_id for p in price_lists}
  4888. supplier_ids = list({sid for sid in supplier_id_by_price_list.values() if sid})
  4889. suppliers = (
  4890. db.query(SysUser).filter(SysUser.id.in_(supplier_ids), SysUser.is_del == 0).all()
  4891. ) if supplier_ids else []
  4892. supplier_name_map = {u.id: (u.real_name or u.username) for u in suppliers}
  4893. out: List[dict] = []
  4894. for o in orders:
  4895. sid = supplier_id_by_price_list.get(o.price_list_id)
  4896. sname = supplier_name_map.get(sid) if sid else None
  4897. ch = _build_order_line_children_for_settlement_list(
  4898. db, o, by_oid.get(o.id, []), pli_map, product_map, unit_map, sid, sname
  4899. )
  4900. sup_ref = sum(c["supplierAmount"] for c in ch)
  4901. normalized_payment_method = order_crud.normalize_payment_method(o.payment_method)
  4902. sel = (
  4903. o.order_status == "completed"
  4904. and normalized_payment_method == "offline"
  4905. and getattr(o, "client_settlement_status", None) == "pending"
  4906. )
  4907. out.append({
  4908. "rowType": "order",
  4909. "id": o.id,
  4910. "orderNo": o.order_no,
  4911. "clientId": o.client_id,
  4912. "clientName": client_map.get(o.client_id),
  4913. "regionId": o.region_id,
  4914. "regionName": region_map.get(o.region_id),
  4915. "supplierId": supplier_id_by_price_list.get(o.price_list_id),
  4916. "supplierName": supplier_name_map.get(supplier_id_by_price_list.get(o.price_list_id)),
  4917. "totalAmount": float(o.total_amount or 0),
  4918. "settlementAmount": float(o.total_amount or 0),
  4919. "supplierReferenceTotal": round(sup_ref, 2),
  4920. "orderStatus": o.order_status,
  4921. "deliveryStatus": getattr(o, "delivery_status", None) or "pending_dispatch",
  4922. "settlementSelectable": sel,
  4923. "paymentMethod": normalized_payment_method,
  4924. "clientSettlementStatus": o.client_settlement_status,
  4925. "supplierSettlementStatus": o.supplier_settlement_status,
  4926. "createAt": str(o.create_at) if o.create_at else None,
  4927. "children": ch,
  4928. })
  4929. return out
  4930. def _build_supplier_pending_settlement_tree_rows(
  4931. db: Session,
  4932. order_supplier_pairs: List[Tuple[BizOrder, Optional[str]]],
  4933. supplier_map: dict,
  4934. ) -> List[dict]:
  4935. if not order_supplier_pairs:
  4936. return []
  4937. # 避免模块级循环依赖:在需要处局部导入
  4938. from app.crud.order import order_crud
  4939. orders = [p[0] for p in order_supplier_pairs]
  4940. sup_by_oid = {p[0].id: p[1] for p in order_supplier_pairs}
  4941. order_ids = [o.id for o in orders]
  4942. lines = (
  4943. db.query(BizOrderItem)
  4944. .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
  4945. .order_by(BizOrderItem.order_id.asc(), BizOrderItem.create_at.asc())
  4946. .all()
  4947. )
  4948. by_oid: dict = {}
  4949. for ln in lines:
  4950. by_oid.setdefault(ln.order_id, []).append(ln)
  4951. product_ids = list({ln.product_id for ln in lines})
  4952. unit_ids = list({ln.unit_id for ln in lines})
  4953. products = (
  4954. db.query(BasProduct).filter(BasProduct.id.in_(product_ids), BasProduct.is_del == 0).all()
  4955. ) if product_ids else []
  4956. product_map = {p.id: p for p in products}
  4957. units = (
  4958. db.query(BasProductUnit).filter(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0).all()
  4959. ) if unit_ids else []
  4960. unit_map = {u.id: u for u in units}
  4961. pl_ids = list({o.price_list_id for o in orders if o.price_list_id})
  4962. pli_map = _price_list_item_map(db, pl_ids)
  4963. client_ids = list({o.client_id for o in orders if o.client_id})
  4964. client_map: dict = {}
  4965. if client_ids:
  4966. clients = db.query(SysUser).filter(SysUser.id.in_(client_ids), SysUser.is_del == 0).all()
  4967. client_map = {u.id: (u.real_name or u.username) for u in clients}
  4968. region_ids = list({o.region_id for o in orders if o.region_id})
  4969. region_map: dict = {}
  4970. for rid in region_ids:
  4971. region = region_crud.get(db, rid)
  4972. region_map[rid] = region.name if region else None
  4973. out: List[dict] = []
  4974. for o in orders:
  4975. sid = sup_by_oid.get(o.id)
  4976. sname = supplier_map.get(sid) if sid else None
  4977. ch = _build_order_line_children_for_settlement_list(
  4978. db, o, by_oid.get(o.id, []), pli_map, product_map, unit_map, sid, sname
  4979. )
  4980. sup_order_total = sum(c["supplierAmount"] for c in ch)
  4981. normalized_payment_method = order_crud.normalize_payment_method(o.payment_method)
  4982. sel = (
  4983. o.order_status == "completed"
  4984. and normalized_payment_method == "offline"
  4985. and getattr(o, "client_settlement_status", None) == "pending"
  4986. )
  4987. out.append({
  4988. "rowType": "order",
  4989. "id": o.id,
  4990. "orderNo": o.order_no,
  4991. "clientId": o.client_id,
  4992. "clientName": client_map.get(o.client_id),
  4993. "regionId": o.region_id,
  4994. "regionName": region_map.get(o.region_id),
  4995. "supplierId": sid,
  4996. "supplierName": supplier_map.get(sid) if sid else None,
  4997. "settlementAmount": round(sup_order_total, 2),
  4998. "totalAmount": float(o.total_amount or 0),
  4999. "clientSellingTotal": float(o.total_amount or 0),
  5000. "orderStatus": o.order_status,
  5001. "deliveryStatus": getattr(o, "delivery_status", None) or "pending_dispatch",
  5002. "settlementSelectable": sel,
  5003. "supplierSettlementStatus": o.supplier_settlement_status,
  5004. "clientSettlementStatus": o.client_settlement_status,
  5005. "paymentMethod": normalized_payment_method,
  5006. "createAt": str(o.create_at) if o.create_at else None,
  5007. "children": ch,
  5008. })
  5009. return out
  5010. @router.get("/client-settlements/orders", response_model=dict)
  5011. def list_client_settlement_orders(
  5012. page: int = Query(1, ge=1),
  5013. pageSize: int = Query(10, ge=1, le=100),
  5014. clientId: Optional[str] = Query(None),
  5015. keyword: Optional[str] = Query(None, description="订单号"),
  5016. startDate: Optional[str] = Query(None),
  5017. endDate: Optional[str] = Query(None),
  5018. regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选,区域管理员忽略"),
  5019. db: Session = Depends(get_db),
  5020. current_user: dict = Depends(require_admin),
  5021. ):
  5022. admin_region_id = get_admin_region_filter(db, current_user)
  5023. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  5024. if allowed_region_ids is not None and regionId and regionId not in allowed_region_ids:
  5025. return error_response(403, "无权访问该区域数据")
  5026. eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
  5027. dt_start, dt_end = _parse_datetime_range(startDate, endDate)
  5028. q = db.query(BizOrder).filter(
  5029. BizOrder.is_del == 0,
  5030. BizOrder.order_status != "cancelled",
  5031. BizOrder.payment_method == "offline",
  5032. BizOrder.client_settlement_status == "pending",
  5033. )
  5034. if eff_region_id:
  5035. q = q.filter(BizOrder.region_id == eff_region_id)
  5036. if clientId:
  5037. q = q.filter(BizOrder.client_id == clientId)
  5038. if keyword:
  5039. q = q.filter(BizOrder.order_no.like(f"%{keyword}%"))
  5040. if dt_start:
  5041. q = q.filter(BizOrder.create_at >= dt_start)
  5042. if dt_end:
  5043. q = q.filter(BizOrder.create_at <= dt_end)
  5044. total = q.count()
  5045. settleable_order_count = q.filter(BizOrder.order_status == "completed").count()
  5046. q_done = q.filter(BizOrder.order_status == "completed")
  5047. pending_amount = float(
  5048. q_done.with_entities(func.coalesce(func.sum(BizOrder.total_amount), 0)).scalar() or 0
  5049. )
  5050. rows = q.order_by(BizOrder.create_at.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
  5051. client_ids = list({r.client_id for r in rows if r.client_id})
  5052. client_map = {}
  5053. if client_ids:
  5054. for u in db.query(SysUser).filter(SysUser.id.in_(client_ids)).all():
  5055. client_map[u.id] = u.real_name or u.username
  5056. data = _build_client_pending_settlement_tree_rows(db, rows, client_map)
  5057. current_page_amount = float(sum((o.total_amount or 0) for o in rows if o.order_status == "completed"))
  5058. return success_response({
  5059. "list": data,
  5060. "total": total,
  5061. "page": page,
  5062. "pageSize": pageSize,
  5063. "totalPages": (total + pageSize - 1) // pageSize,
  5064. "summary": {
  5065. "pendingAmount": pending_amount,
  5066. "currentPageAmount": current_page_amount,
  5067. "settleableOrderCount": settleable_order_count,
  5068. },
  5069. })
  5070. @router.get("/supplier-settlements/orders", response_model=dict)
  5071. def list_supplier_settlement_orders(
  5072. page: int = Query(1, ge=1),
  5073. pageSize: int = Query(10, ge=1, le=100),
  5074. supplierId: Optional[str] = Query(None),
  5075. keyword: Optional[str] = Query(None, description="订单号"),
  5076. startDate: Optional[str] = Query(None),
  5077. endDate: Optional[str] = Query(None),
  5078. regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选,区域管理员忽略"),
  5079. db: Session = Depends(get_db),
  5080. current_user: dict = Depends(require_admin),
  5081. ):
  5082. admin_region_id = get_admin_region_filter(db, current_user)
  5083. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  5084. if allowed_region_ids is not None and regionId and regionId not in allowed_region_ids:
  5085. return error_response(403, "无权访问该区域数据")
  5086. eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
  5087. dt_start, dt_end = _parse_datetime_range(startDate, endDate)
  5088. q = db.query(BizOrder, BizPriceList.supplier_id).join(
  5089. BizPriceList, BizPriceList.id == BizOrder.price_list_id
  5090. ).filter(
  5091. BizOrder.is_del == 0,
  5092. BizOrder.order_status != "cancelled",
  5093. BizOrder.supplier_settlement_status == "pending",
  5094. )
  5095. if eff_region_id:
  5096. q = q.filter(BizOrder.region_id == eff_region_id)
  5097. if supplierId:
  5098. q = q.filter(BizPriceList.supplier_id == supplierId)
  5099. if keyword:
  5100. q = q.filter(BizOrder.order_no.like(f"%{keyword}%"))
  5101. if dt_start:
  5102. q = q.filter(BizOrder.create_at >= dt_start)
  5103. if dt_end:
  5104. q = q.filter(BizOrder.create_at <= dt_end)
  5105. total = q.count()
  5106. settleable_order_count = q.filter(BizOrder.order_status == "completed").count()
  5107. q_done = q.filter(BizOrder.order_status == "completed")
  5108. done_pairs = q_done.with_entities(BizOrder.id).all()
  5109. done_oids = [x[0] for x in done_pairs]
  5110. totals_map = _batch_supplier_totals_for_orders(db, done_oids)
  5111. pending_amount = float(sum(totals_map.values()))
  5112. rows = q.order_by(BizOrder.create_at.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
  5113. supplier_ids = list({s_id for _, s_id in rows if s_id})
  5114. supplier_map = {}
  5115. if supplier_ids:
  5116. for u in db.query(SysUser).filter(SysUser.id.in_(supplier_ids)).all():
  5117. supplier_map[u.id] = u.real_name or u.username
  5118. data = _build_supplier_pending_settlement_tree_rows(db, rows, supplier_map)
  5119. page_done_ids = [o.id for o, _ in rows if o.order_status == "completed"]
  5120. page_totals = _batch_supplier_totals_for_orders(db, page_done_ids)
  5121. current_page_amount = float(sum(page_totals.values()))
  5122. return success_response({
  5123. "list": data,
  5124. "total": total,
  5125. "page": page,
  5126. "pageSize": pageSize,
  5127. "totalPages": (total + pageSize - 1) // pageSize,
  5128. "summary": {
  5129. "pendingAmount": pending_amount,
  5130. "currentPageAmount": current_page_amount,
  5131. "settleableOrderCount": settleable_order_count,
  5132. },
  5133. })
  5134. @router.get("/client-settlements/history-summary", response_model=dict)
  5135. def get_client_settlement_history_summary(
  5136. regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选"),
  5137. clientId: Optional[str] = Query(None, description="客户ID;不传则汇总当前区域下全部客户"),
  5138. db: Session = Depends(get_db),
  5139. current_user: dict = Depends(require_admin),
  5140. ):
  5141. """待结算页用:当前检索条件下已结算批次的历史金额与历史订单笔数。"""
  5142. admin_region_id = get_admin_region_filter(db, current_user)
  5143. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  5144. if allowed_region_ids is not None and regionId and regionId not in allowed_region_ids:
  5145. return error_response(403, "无权访问该区域数据")
  5146. eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
  5147. amt, cnt = _aggregate_client_settlement_history(db, eff_region_id, clientId)
  5148. return success_response({
  5149. "historicalSettledAmount": amt,
  5150. "historicalSettledOrderCount": cnt,
  5151. })
  5152. @router.get("/supplier-settlements/history-summary", response_model=dict)
  5153. def get_supplier_settlement_history_summary(
  5154. regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选"),
  5155. supplierId: Optional[str] = Query(None, description="供应商ID;不传则汇总当前区域下全部供应商"),
  5156. db: Session = Depends(get_db),
  5157. current_user: dict = Depends(require_admin),
  5158. ):
  5159. """待结算页用:当前检索条件下已结算批次的历史金额与历史订单笔数。"""
  5160. admin_region_id = get_admin_region_filter(db, current_user)
  5161. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  5162. if allowed_region_ids is not None and regionId and regionId not in allowed_region_ids:
  5163. return error_response(403, "无权访问该区域数据")
  5164. eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
  5165. amt, cnt = _aggregate_supplier_settlement_history(db, eff_region_id, supplierId)
  5166. return success_response({
  5167. "historicalSettledAmount": amt,
  5168. "historicalSettledOrderCount": cnt,
  5169. })
  5170. def _resolve_client_order_ids(db: Session, body: dict, admin_region_id: Optional[str]) -> List[str]:
  5171. selection_mode = (body.get("selectionMode") or "ids").strip()
  5172. filters = body.get("filters") or {}
  5173. eff_region = _effective_settlement_region_id(admin_region_id, filters.get("regionId"))
  5174. if selection_mode == "allFiltered":
  5175. dt_start, dt_end = _parse_datetime_range(filters.get("startDate"), filters.get("endDate"))
  5176. q = db.query(BizOrder.id).filter(
  5177. BizOrder.is_del == 0,
  5178. BizOrder.order_status != "cancelled",
  5179. BizOrder.order_status == "completed",
  5180. BizOrder.payment_method == "offline",
  5181. BizOrder.client_settlement_status == "pending",
  5182. )
  5183. if eff_region:
  5184. q = q.filter(BizOrder.region_id == eff_region)
  5185. if filters.get("clientId"):
  5186. q = q.filter(BizOrder.client_id == filters["clientId"])
  5187. if filters.get("keyword"):
  5188. q = q.filter(BizOrder.order_no.like(f"%{filters['keyword']}%"))
  5189. if dt_start:
  5190. q = q.filter(BizOrder.create_at >= dt_start)
  5191. if dt_end:
  5192. q = q.filter(BizOrder.create_at <= dt_end)
  5193. order_ids = [x[0] for x in q.all()]
  5194. excluded = set(body.get("excludedOrderIds") or [])
  5195. return [oid for oid in order_ids if oid not in excluded]
  5196. return list(set(body.get("orderIds") or []))
  5197. def _resolve_supplier_order_ids(db: Session, body: dict, admin_region_id: Optional[str]) -> List[str]:
  5198. selection_mode = (body.get("selectionMode") or "ids").strip()
  5199. filters = body.get("filters") or {}
  5200. eff_region = _effective_settlement_region_id(admin_region_id, filters.get("regionId"))
  5201. if selection_mode == "allFiltered":
  5202. dt_start, dt_end = _parse_datetime_range(filters.get("startDate"), filters.get("endDate"))
  5203. q = db.query(BizOrder.id).join(
  5204. BizPriceList, BizPriceList.id == BizOrder.price_list_id
  5205. ).filter(
  5206. BizOrder.is_del == 0,
  5207. BizOrder.order_status != "cancelled",
  5208. BizOrder.order_status == "completed",
  5209. BizOrder.supplier_settlement_status == "pending",
  5210. )
  5211. if eff_region:
  5212. q = q.filter(BizOrder.region_id == eff_region)
  5213. if filters.get("supplierId"):
  5214. q = q.filter(BizPriceList.supplier_id == filters["supplierId"])
  5215. if filters.get("keyword"):
  5216. q = q.filter(BizOrder.order_no.like(f"%{filters['keyword']}%"))
  5217. if dt_start:
  5218. q = q.filter(BizOrder.create_at >= dt_start)
  5219. if dt_end:
  5220. q = q.filter(BizOrder.create_at <= dt_end)
  5221. order_ids = [x[0] for x in q.all()]
  5222. excluded = set(body.get("excludedOrderIds") or [])
  5223. return [oid for oid in order_ids if oid not in excluded]
  5224. return list(set(body.get("orderIds") or []))
  5225. @router.post("/client-settlements", response_model=dict)
  5226. def create_client_settlement(
  5227. request: dict,
  5228. db: Session = Depends(get_db),
  5229. current_user: dict = Depends(require_admin),
  5230. ):
  5231. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  5232. if req_err is not None:
  5233. return req_err
  5234. admin_region_id = get_admin_region_filter(db, current_user)
  5235. body = dict(request or {})
  5236. voucher_urls = body.get("voucherUrls") or []
  5237. order_ids = _resolve_client_order_ids(db, body, admin_region_id)
  5238. if not order_ids:
  5239. return error_response(400, "未选择可结算订单")
  5240. orders = db.query(BizOrder).filter(
  5241. BizOrder.id.in_(order_ids),
  5242. BizOrder.is_del == 0,
  5243. BizOrder.order_status == "completed",
  5244. BizOrder.payment_method == "offline",
  5245. BizOrder.client_settlement_status == "pending",
  5246. ).all()
  5247. if not orders:
  5248. return error_response(400, "无可结算订单")
  5249. eff_region = _effective_settlement_region_id(
  5250. admin_region_id, (body.get("filters") or {}).get("regionId")
  5251. )
  5252. if eff_region and any((o.region_id or "") != eff_region for o in orders):
  5253. return error_response(400, "存在不属于当前区域的订单")
  5254. total_amount = sum((o.total_amount or 0) for o in orders)
  5255. client_id = orders[0].client_id
  5256. if any(o.client_id != client_id for o in orders):
  5257. return error_response(400, "客户结算必须选择同一客户")
  5258. client = user_crud.get(db, client_id)
  5259. now = datetime.now()
  5260. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  5261. settlement = OrdClientSettlement(
  5262. id=generate_id(),
  5263. settlement_no=_build_settlement_no("CST"),
  5264. client_id=client_id,
  5265. client_name=(client.real_name if client else None),
  5266. order_count=len(orders),
  5267. total_amount=total_amount,
  5268. voucher_urls=json.dumps(voucher_urls, ensure_ascii=False),
  5269. remark=body.get("remark"),
  5270. settlement_at=now,
  5271. status="done",
  5272. create_by=creator["user_id"],
  5273. create_name=creator["real_name"],
  5274. update_by=creator["user_id"],
  5275. )
  5276. db.add(settlement)
  5277. db.flush()
  5278. from app.crud.order import order_crud
  5279. for order in orders:
  5280. db.add(OrdClientSettlementItem(
  5281. id=generate_id(),
  5282. settlement_id=settlement.id,
  5283. order_id=order.id,
  5284. order_no=order.order_no,
  5285. client_id=order.client_id,
  5286. order_amount=order.total_amount or 0,
  5287. create_by=creator["user_id"],
  5288. create_name=creator["real_name"],
  5289. update_by=creator["user_id"],
  5290. ))
  5291. order.client_settlement_status = "settled"
  5292. order.client_settlement_at = now
  5293. order.update_by = creator["user_id"]
  5294. order.update_at = now
  5295. # 客户结算即视为线下收款对账完成:同步支付状态(不覆盖退款中)
  5296. ps = (getattr(order, "pay_status", None) or "").strip()
  5297. if ps != "refunding":
  5298. ra = order.receivable_amount or Decimal("0")
  5299. ta = order.total_amount or Decimal("0")
  5300. pa = order.paid_amount or Decimal("0")
  5301. if ra > Decimal("0") and pa < ra:
  5302. order.paid_amount = ra
  5303. elif ta > Decimal("0") and pa < ta:
  5304. order.paid_amount = ta
  5305. if not order.payment_time:
  5306. order.payment_time = now
  5307. order_crud._apply_three_status(order, pay_status="paid")
  5308. db.commit()
  5309. return success_response({
  5310. "id": settlement.id,
  5311. "settlementNo": settlement.settlement_no,
  5312. "orderCount": settlement.order_count,
  5313. "totalAmount": float(settlement.total_amount or 0),
  5314. })
  5315. @router.post("/supplier-settlements", response_model=dict)
  5316. def create_supplier_settlement(
  5317. request: dict,
  5318. db: Session = Depends(get_db),
  5319. current_user: dict = Depends(require_admin),
  5320. ):
  5321. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  5322. if req_err is not None:
  5323. return req_err
  5324. admin_region_id = get_admin_region_filter(db, current_user)
  5325. body = dict(request or {})
  5326. voucher_urls = body.get("voucherUrls") or []
  5327. order_ids = _resolve_supplier_order_ids(db, body, admin_region_id)
  5328. if not order_ids:
  5329. return error_response(400, "未选择可结算订单")
  5330. rows = db.query(BizOrder, BizPriceList.supplier_id).join(
  5331. BizPriceList, BizPriceList.id == BizOrder.price_list_id
  5332. ).filter(
  5333. BizOrder.id.in_(order_ids),
  5334. BizOrder.is_del == 0,
  5335. BizOrder.order_status == "completed",
  5336. BizOrder.supplier_settlement_status == "pending",
  5337. ).all()
  5338. if not rows:
  5339. return error_response(400, "无可结算订单")
  5340. eff_region = _effective_settlement_region_id(
  5341. admin_region_id, (body.get("filters") or {}).get("regionId")
  5342. )
  5343. if eff_region and any((order.region_id or "") != eff_region for order, _ in rows):
  5344. return error_response(400, "存在不属于当前区域的订单")
  5345. supplier_id = rows[0][1]
  5346. if any(s_id != supplier_id for _, s_id in rows):
  5347. return error_response(400, "商家结算必须选择同一供应商")
  5348. orders = [r[0] for r in rows]
  5349. supplier_totals = _batch_supplier_totals_for_orders(db, [o.id for o in orders])
  5350. total_amount = sum(supplier_totals.get(o.id, 0) for o in orders)
  5351. supplier = user_crud.get(db, supplier_id) if supplier_id else None
  5352. now = datetime.now()
  5353. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  5354. settlement = OrdSupplierSettlement(
  5355. id=generate_id(),
  5356. settlement_no=_build_settlement_no("SST"),
  5357. supplier_id=supplier_id,
  5358. supplier_name=(supplier.real_name if supplier else None),
  5359. order_count=len(orders),
  5360. total_amount=total_amount,
  5361. voucher_urls=json.dumps(voucher_urls, ensure_ascii=False),
  5362. remark=body.get("remark"),
  5363. settlement_at=now,
  5364. status="done",
  5365. create_by=creator["user_id"],
  5366. create_name=creator["real_name"],
  5367. update_by=creator["user_id"],
  5368. )
  5369. db.add(settlement)
  5370. db.flush()
  5371. for order in orders:
  5372. db.add(OrdSupplierSettlementItem(
  5373. id=generate_id(),
  5374. settlement_id=settlement.id,
  5375. order_id=order.id,
  5376. order_no=order.order_no,
  5377. supplier_id=supplier_id,
  5378. order_amount=supplier_totals.get(order.id, 0),
  5379. create_by=creator["user_id"],
  5380. create_name=creator["real_name"],
  5381. update_by=creator["user_id"],
  5382. ))
  5383. order.supplier_settlement_status = "settled"
  5384. order.supplier_settlement_at = now
  5385. order.update_by = creator["user_id"]
  5386. order.update_at = now
  5387. db.commit()
  5388. return success_response({
  5389. "id": settlement.id,
  5390. "settlementNo": settlement.settlement_no,
  5391. "orderCount": settlement.order_count,
  5392. "totalAmount": float(settlement.total_amount or 0),
  5393. })
  5394. def _sheet_voucher_urls_list(raw) -> list:
  5395. if not raw:
  5396. return []
  5397. if isinstance(raw, list):
  5398. return [str(x) for x in raw if x]
  5399. s = str(raw).strip()
  5400. if not s:
  5401. return []
  5402. try:
  5403. data = json.loads(s)
  5404. if isinstance(data, list):
  5405. return [str(x) for x in data if x]
  5406. except Exception:
  5407. pass
  5408. return [s]
  5409. def _build_settlement_detail_item_tree(db: Session, settlement_items: List[Any]) -> List[dict]:
  5410. """结算单明细:每个订单一行父节点,子行为订单商品行(与前端树表对应)。"""
  5411. from app.crud.order import order_crud
  5412. if not settlement_items:
  5413. return []
  5414. order_ids = list({it.order_id for it in settlement_items if getattr(it, "order_id", None)})
  5415. if not order_ids:
  5416. return []
  5417. orders = (
  5418. db.query(BizOrder)
  5419. .filter(BizOrder.id.in_(order_ids), BizOrder.is_del == 0)
  5420. .all()
  5421. )
  5422. order_map = {o.id: o for o in orders}
  5423. client_ids = list({o.client_id for o in orders if o.client_id})
  5424. client_map: dict = {}
  5425. if client_ids:
  5426. clients = db.query(SysUser).filter(SysUser.id.in_(client_ids), SysUser.is_del == 0).all()
  5427. client_map = {u.id: (u.real_name or u.username) for u in clients}
  5428. region_ids = list({o.region_id for o in orders if o.region_id})
  5429. region_map: dict = {}
  5430. for rid in region_ids:
  5431. r = region_crud.get(db, rid)
  5432. region_map[rid] = r.name if r else None
  5433. lines = (
  5434. db.query(BizOrderItem)
  5435. .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
  5436. .order_by(BizOrderItem.order_id.asc(), BizOrderItem.create_at.asc())
  5437. .all()
  5438. )
  5439. by_order: dict = {}
  5440. for ln in lines:
  5441. by_order.setdefault(ln.order_id, []).append(ln)
  5442. product_ids = list({ln.product_id for ln in lines})
  5443. unit_ids = list({ln.unit_id for ln in lines})
  5444. products = (
  5445. db.query(BasProduct)
  5446. .filter(BasProduct.id.in_(product_ids), BasProduct.is_del == 0)
  5447. .all()
  5448. ) if product_ids else []
  5449. product_map = {p.id: p for p in products}
  5450. category_ids = list({p.category_id for p in products if p.category_id})
  5451. category_map: dict = {}
  5452. for cid in category_ids:
  5453. c = category_crud.get(db, cid)
  5454. category_map[cid] = c.name if c else None
  5455. units = (
  5456. db.query(BasProductUnit)
  5457. .filter(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0)
  5458. .all()
  5459. ) if unit_ids else []
  5460. unit_map = {u.id: u for u in units}
  5461. pl_ids = list({o.price_list_id for o in order_map.values() if o and o.price_list_id})
  5462. pli_map = _price_list_item_map(db, pl_ids)
  5463. price_lists = (
  5464. db.query(BizPriceList).filter(BizPriceList.id.in_(pl_ids), BizPriceList.is_del == 0).all()
  5465. ) if pl_ids else []
  5466. supplier_id_by_price_list = {p.id: p.supplier_id for p in price_lists}
  5467. supplier_ids = list({sid for sid in supplier_id_by_price_list.values() if sid})
  5468. suppliers = (
  5469. db.query(SysUser).filter(SysUser.id.in_(supplier_ids), SysUser.is_del == 0).all()
  5470. ) if supplier_ids else []
  5471. supplier_name_map = {u.id: (u.real_name or u.username) for u in suppliers}
  5472. out: List[dict] = []
  5473. for it in settlement_items:
  5474. order = order_map.get(it.order_id)
  5475. rid = order.region_id if order else None
  5476. children: List[dict] = []
  5477. for ln in by_order.get(it.order_id, []):
  5478. p = product_map.get(ln.product_id)
  5479. u = unit_map.get(ln.unit_id)
  5480. img = None
  5481. if p and getattr(p, "image_url", None):
  5482. img = str(p.image_url).strip() or None
  5483. mv = None
  5484. if p is not None and p.measure_value is not None:
  5485. try:
  5486. mv = float(p.measure_value)
  5487. except (TypeError, ValueError):
  5488. mv = None
  5489. pli = pli_map.get((order.price_list_id, ln.product_id, ln.unit_id)) if order else None
  5490. sup_u = _supplier_unit_yuan_from_pli(pli)
  5491. if sup_u is None:
  5492. sup_u = float(ln.unit_price or 0)
  5493. sell_u = float(ln.unit_price or 0)
  5494. qty = float(ln.quantity or 0)
  5495. sell_amt = float(ln.amount or 0) if ln.amount is not None else round(qty * sell_u, 2)
  5496. sup_amt = round(qty * sup_u, 2)
  5497. supplier_id = supplier_id_by_price_list.get(order.price_list_id) if order else None
  5498. children.append({
  5499. "rowType": "line",
  5500. "id": f"line-{ln.id}",
  5501. "lineId": ln.id,
  5502. "productId": ln.product_id,
  5503. "productName": p.name if p else None,
  5504. "skuCode": p.sku_code if p else None,
  5505. "imageUrl": img,
  5506. "categoryName": category_map.get(p.category_id) if p and p.category_id else None,
  5507. "unitName": u.name if u else None,
  5508. "measureValue": mv,
  5509. "spec": getattr(p, "spec", None) if p else None,
  5510. "quantity": qty,
  5511. "unitPrice": sell_u,
  5512. "amount": sell_amt,
  5513. "sellingUnitPrice": sell_u,
  5514. "sellingAmount": sell_amt,
  5515. "supplierUnitPrice": sup_u,
  5516. "supplierAmount": sup_amt,
  5517. "supplierId": supplier_id,
  5518. "supplierName": supplier_name_map.get(supplier_id),
  5519. "deliveredQuantity": float(ln.delivered_quantity or 0),
  5520. "note": ln.note,
  5521. })
  5522. supplier_id = supplier_id_by_price_list.get(order.price_list_id) if order else None
  5523. out.append({
  5524. "rowType": "order",
  5525. "id": f"order-{it.id}",
  5526. "settlementItemId": it.id,
  5527. "orderId": it.order_id,
  5528. "orderNo": it.order_no,
  5529. "orderAmount": float(it.order_amount or 0),
  5530. "totalAmount": float(it.order_amount or 0),
  5531. "clientId": order.client_id if order else None,
  5532. "clientName": client_map.get(order.client_id) if order else None,
  5533. "supplierId": supplier_id,
  5534. "supplierName": supplier_name_map.get(supplier_id),
  5535. "regionId": rid,
  5536. "regionName": region_map.get(rid),
  5537. "orderCreateAt": str(order.create_at) if order and order.create_at else None,
  5538. "contactPerson": order.contact_person if order else None,
  5539. "contactPhone": order.contact_phone if order else None,
  5540. "deliveryAddress": order.delivery_address if order else None,
  5541. "orderStatus": order.order_status if order else None,
  5542. "deliveryStatus": (getattr(order, "delivery_status", None) or "pending_dispatch") if order else None,
  5543. "paymentMethod": order_crud.normalize_payment_method(order.payment_method) if order else None,
  5544. "createAt": str(it.create_at) if it.create_at else None,
  5545. "children": children,
  5546. })
  5547. return out
  5548. @router.get("/client-settlements/sheets", response_model=dict)
  5549. def list_client_settlement_sheets(
  5550. page: int = Query(1, ge=1),
  5551. pageSize: int = Query(10, ge=1, le=100),
  5552. clientId: Optional[str] = Query(None),
  5553. settlementNo: Optional[str] = Query(None),
  5554. startDate: Optional[str] = Query(None),
  5555. endDate: Optional[str] = Query(None),
  5556. regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选"),
  5557. db: Session = Depends(get_db),
  5558. current_user: dict = Depends(require_admin),
  5559. ):
  5560. """客户结算单(批次)列表"""
  5561. from app.crud.ord_settlement import ord_client_settlement_crud
  5562. admin_region_id = get_admin_region_filter(db, current_user)
  5563. eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
  5564. dt_start, dt_end = _parse_datetime_range(startDate, endDate)
  5565. rows, total = ord_client_settlement_crud.get_multi_with_filters(
  5566. db,
  5567. client_id=clientId,
  5568. settlement_no=settlementNo,
  5569. start_date=dt_start,
  5570. end_date=dt_end,
  5571. region_id=eff_region_id,
  5572. skip=(page - 1) * pageSize,
  5573. limit=pageSize,
  5574. )
  5575. out = []
  5576. for s in rows:
  5577. out.append({
  5578. "id": s.id,
  5579. "settlementNo": s.settlement_no,
  5580. "clientId": s.client_id,
  5581. "clientName": s.client_name,
  5582. "orderCount": s.order_count,
  5583. "totalAmount": float(s.total_amount or 0),
  5584. "settlementAt": str(s.settlement_at) if s.settlement_at else None,
  5585. "status": s.status,
  5586. "remark": s.remark,
  5587. "createAt": str(s.create_at) if s.create_at else None,
  5588. "createName": s.create_name,
  5589. })
  5590. return success_response({
  5591. "list": out,
  5592. "total": total,
  5593. "page": page,
  5594. "pageSize": pageSize,
  5595. "totalPages": (total + pageSize - 1) // pageSize,
  5596. })
  5597. @router.get("/client-settlements/sheets/{sheet_id}", response_model=dict)
  5598. def get_client_settlement_sheet_detail(
  5599. sheet_id: str,
  5600. db: Session = Depends(get_db),
  5601. current_user: dict = Depends(require_admin),
  5602. ):
  5603. """客户结算单详情(含明细订单)"""
  5604. from app.crud.ord_settlement import ord_client_settlement_crud
  5605. admin_region_id = get_admin_region_filter(db, current_user)
  5606. main, items = ord_client_settlement_crud.get_with_items(db, sheet_id)
  5607. if not main:
  5608. return error_response(404, "结算单不存在")
  5609. if admin_region_id and not ord_client_settlement_crud.belongs_to_region(db, sheet_id, admin_region_id):
  5610. return error_response(403, "无权限查看该结算单")
  5611. item_out = _build_settlement_detail_item_tree(db, items)
  5612. return success_response({
  5613. "id": main.id,
  5614. "settlementNo": main.settlement_no,
  5615. "clientId": main.client_id,
  5616. "clientName": main.client_name,
  5617. "orderCount": main.order_count,
  5618. "totalAmount": float(main.total_amount or 0),
  5619. "voucherUrls": _sheet_voucher_urls_list(main.voucher_urls),
  5620. "remark": main.remark,
  5621. "settlementAt": str(main.settlement_at) if main.settlement_at else None,
  5622. "status": main.status,
  5623. "createAt": str(main.create_at) if main.create_at else None,
  5624. "createName": main.create_name,
  5625. "items": item_out,
  5626. })
  5627. @router.put("/client-settlements/sheets/{sheet_id}", response_model=dict)
  5628. def update_client_settlement_sheet(
  5629. sheet_id: str,
  5630. request: dict,
  5631. db: Session = Depends(get_db),
  5632. current_user: dict = Depends(require_admin),
  5633. ):
  5634. """更新客户结算单(备注/凭证);UPDATE 前写入 ord_client_settlement_his"""
  5635. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  5636. if req_err is not None:
  5637. return req_err
  5638. from app.crud.ord_settlement import ord_client_settlement_crud
  5639. admin_region_id = get_admin_region_filter(db, current_user)
  5640. if admin_region_id and not ord_client_settlement_crud.belongs_to_region(db, sheet_id, admin_region_id):
  5641. return error_response(403, "无权限操作该结算单")
  5642. body = dict(request or {})
  5643. update_data = {}
  5644. if "remark" in body:
  5645. update_data["remark"] = body.get("remark")
  5646. if "voucherUrls" in body:
  5647. urls = body.get("voucherUrls") or []
  5648. if not isinstance(urls, list):
  5649. return error_response(400, "voucherUrls 须为数组")
  5650. update_data["voucher_urls"] = json.dumps(urls, ensure_ascii=False)
  5651. if not update_data:
  5652. return error_response(400, "无可更新字段")
  5653. updater = {"user_id": current_user.get("user_id")}
  5654. row = ord_client_settlement_crud.update_with_history(db, sheet_id, update_data, updater)
  5655. if not row:
  5656. return error_response(404, "结算单不存在")
  5657. return success_response({
  5658. "id": row.id,
  5659. "settlementNo": row.settlement_no,
  5660. "remark": row.remark,
  5661. "voucherUrls": _sheet_voucher_urls_list(row.voucher_urls),
  5662. })
  5663. @router.get("/supplier-settlements/sheets", response_model=dict)
  5664. def list_supplier_settlement_sheets(
  5665. page: int = Query(1, ge=1),
  5666. pageSize: int = Query(10, ge=1, le=100),
  5667. supplierId: Optional[str] = Query(None),
  5668. settlementNo: Optional[str] = Query(None),
  5669. startDate: Optional[str] = Query(None),
  5670. endDate: Optional[str] = Query(None),
  5671. regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选"),
  5672. db: Session = Depends(get_db),
  5673. current_user: dict = Depends(require_admin),
  5674. ):
  5675. """供应商结算单(批次)列表"""
  5676. from app.crud.ord_settlement import ord_supplier_settlement_crud
  5677. admin_region_id = get_admin_region_filter(db, current_user)
  5678. eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
  5679. dt_start, dt_end = _parse_datetime_range(startDate, endDate)
  5680. rows, total = ord_supplier_settlement_crud.get_multi_with_filters(
  5681. db,
  5682. supplier_id=supplierId,
  5683. settlement_no=settlementNo,
  5684. start_date=dt_start,
  5685. end_date=dt_end,
  5686. region_id=eff_region_id,
  5687. skip=(page - 1) * pageSize,
  5688. limit=pageSize,
  5689. )
  5690. out = []
  5691. for s in rows:
  5692. out.append({
  5693. "id": s.id,
  5694. "settlementNo": s.settlement_no,
  5695. "supplierId": s.supplier_id,
  5696. "supplierName": s.supplier_name,
  5697. "orderCount": s.order_count,
  5698. "totalAmount": float(s.total_amount or 0),
  5699. "settlementAt": str(s.settlement_at) if s.settlement_at else None,
  5700. "status": s.status,
  5701. "remark": s.remark,
  5702. "createAt": str(s.create_at) if s.create_at else None,
  5703. "createName": s.create_name,
  5704. })
  5705. return success_response({
  5706. "list": out,
  5707. "total": total,
  5708. "page": page,
  5709. "pageSize": pageSize,
  5710. "totalPages": (total + pageSize - 1) // pageSize,
  5711. })
  5712. @router.get("/supplier-settlements/sheets/{sheet_id}", response_model=dict)
  5713. def get_supplier_settlement_sheet_detail(
  5714. sheet_id: str,
  5715. db: Session = Depends(get_db),
  5716. current_user: dict = Depends(require_admin),
  5717. ):
  5718. """供应商结算单详情(含明细订单)"""
  5719. from app.crud.ord_settlement import ord_supplier_settlement_crud
  5720. admin_region_id = get_admin_region_filter(db, current_user)
  5721. main, items = ord_supplier_settlement_crud.get_with_items(db, sheet_id)
  5722. if not main:
  5723. return error_response(404, "结算单不存在")
  5724. if admin_region_id and not ord_supplier_settlement_crud.belongs_to_region(db, sheet_id, admin_region_id):
  5725. return error_response(403, "无权限查看该结算单")
  5726. item_out = _build_settlement_detail_item_tree(db, items)
  5727. return success_response({
  5728. "id": main.id,
  5729. "settlementNo": main.settlement_no,
  5730. "supplierId": main.supplier_id,
  5731. "supplierName": main.supplier_name,
  5732. "orderCount": main.order_count,
  5733. "totalAmount": float(main.total_amount or 0),
  5734. "voucherUrls": _sheet_voucher_urls_list(main.voucher_urls),
  5735. "remark": main.remark,
  5736. "settlementAt": str(main.settlement_at) if main.settlement_at else None,
  5737. "status": main.status,
  5738. "createAt": str(main.create_at) if main.create_at else None,
  5739. "createName": main.create_name,
  5740. "items": item_out,
  5741. })
  5742. @router.put("/supplier-settlements/sheets/{sheet_id}", response_model=dict)
  5743. def update_supplier_settlement_sheet(
  5744. sheet_id: str,
  5745. request: dict,
  5746. db: Session = Depends(get_db),
  5747. current_user: dict = Depends(require_admin),
  5748. ):
  5749. """更新供应商结算单(备注/凭证);UPDATE 前写入 ord_supplier_settlement_his"""
  5750. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  5751. if req_err is not None:
  5752. return req_err
  5753. from app.crud.ord_settlement import ord_supplier_settlement_crud
  5754. admin_region_id = get_admin_region_filter(db, current_user)
  5755. if admin_region_id and not ord_supplier_settlement_crud.belongs_to_region(db, sheet_id, admin_region_id):
  5756. return error_response(403, "无权限操作该结算单")
  5757. body = dict(request or {})
  5758. update_data = {}
  5759. if "remark" in body:
  5760. update_data["remark"] = body.get("remark")
  5761. if "voucherUrls" in body:
  5762. urls = body.get("voucherUrls") or []
  5763. if not isinstance(urls, list):
  5764. return error_response(400, "voucherUrls 须为数组")
  5765. update_data["voucher_urls"] = json.dumps(urls, ensure_ascii=False)
  5766. if not update_data:
  5767. return error_response(400, "无可更新字段")
  5768. updater = {"user_id": current_user.get("user_id")}
  5769. row = ord_supplier_settlement_crud.update_with_history(db, sheet_id, update_data, updater)
  5770. if not row:
  5771. return error_response(404, "结算单不存在")
  5772. return success_response({
  5773. "id": row.id,
  5774. "settlementNo": row.settlement_no,
  5775. "remark": row.remark,
  5776. "voucherUrls": _sheet_voucher_urls_list(row.voucher_urls),
  5777. })
  5778. @router.get("/settlements", response_model=dict)
  5779. def list_settlements(
  5780. page: int = Query(1, ge=1, description="页码"),
  5781. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  5782. regionId: Optional[str] = Query(None, description="区域ID"),
  5783. clientId: Optional[str] = Query(None, description="客户ID"),
  5784. status: Optional[str] = Query(None, description="状态"),
  5785. startDate: Optional[str] = Query(None, description="开始日期"),
  5786. endDate: Optional[str] = Query(None, description="结束日期"),
  5787. db: Session = Depends(get_db),
  5788. current_user: dict = Depends(require_admin)
  5789. ):
  5790. """获取线下结算订单列表"""
  5791. from app.crud.settlement import settlement_crud
  5792. settlements, total = settlement_crud.get_multi_with_filters(
  5793. db,
  5794. region_id=regionId,
  5795. client_id=clientId,
  5796. status=status,
  5797. start_date=startDate,
  5798. end_date=endDate,
  5799. skip=(page - 1) * pageSize,
  5800. limit=pageSize
  5801. )
  5802. region_map = {}
  5803. client_map = {}
  5804. result = []
  5805. for settlement in settlements:
  5806. if settlement.region_id and settlement.region_id not in region_map:
  5807. region = region_crud.get(db, settlement.region_id)
  5808. region_map[settlement.region_id] = region.name if region else None
  5809. if settlement.client_id and settlement.client_id not in client_map:
  5810. client = user_crud.get(db, settlement.client_id)
  5811. client_map[settlement.client_id] = client.real_name if client else None
  5812. result.append({
  5813. "id": settlement.id,
  5814. "orderId": settlement.order_id,
  5815. "clientId": settlement.client_id,
  5816. "clientName": client_map.get(settlement.client_id),
  5817. "regionId": settlement.region_id,
  5818. "regionName": region_map.get(settlement.region_id),
  5819. "amount": float(settlement.amount) if settlement.amount else 0,
  5820. "settlementMethod": settlement.settlement_method,
  5821. "settlementTime": str(settlement.settlement_time) if settlement.settlement_time else None,
  5822. "settlementNote": settlement.settlement_note,
  5823. "status": settlement.status,
  5824. "reviewedBy": settlement.reviewed_by,
  5825. "reviewedTime": str(settlement.reviewed_time) if settlement.reviewed_time else None,
  5826. "createAt": str(settlement.create_at) if settlement.create_at else None
  5827. })
  5828. return success_response({
  5829. "list": result,
  5830. "total": total,
  5831. "page": page,
  5832. "pageSize": pageSize,
  5833. "totalPages": (total + pageSize - 1) // pageSize
  5834. })
  5835. @router.get("/settlements/client-summary", response_model=dict)
  5836. def get_client_summary(
  5837. page: int = Query(1, ge=1, description="页码"),
  5838. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  5839. regionId: Optional[str] = Query(None, description="区域ID"),
  5840. clientId: Optional[str] = Query(None, description="客户ID"),
  5841. db: Session = Depends(get_db),
  5842. current_user: dict = Depends(require_admin)
  5843. ):
  5844. """获取客户待结算汇总"""
  5845. from app.crud.settlement import settlement_crud
  5846. summaries, total = settlement_crud.get_client_summary(
  5847. db,
  5848. region_id=regionId,
  5849. client_id=clientId,
  5850. skip=(page - 1) * pageSize,
  5851. limit=pageSize
  5852. )
  5853. result = []
  5854. for summary in summaries:
  5855. client = user_crud.get(db, summary["client_id"])
  5856. result.append({
  5857. "clientId": summary["client_id"],
  5858. "clientName": client.real_name if client else None,
  5859. "orderCount": summary["order_count"],
  5860. "totalUnpaid": float(summary["total_unpaid"]) if summary["total_unpaid"] else 0
  5861. })
  5862. return success_response({
  5863. "list": result,
  5864. "total": total,
  5865. "page": page,
  5866. "pageSize": pageSize,
  5867. "totalPages": (total + pageSize - 1) // pageSize
  5868. })
  5869. @router.post("/settlements", response_model=dict)
  5870. def create_settlement(
  5871. request: dict,
  5872. db: Session = Depends(get_db),
  5873. current_user: dict = Depends(require_admin)
  5874. ):
  5875. """确认线下结算"""
  5876. from app.crud.settlement import settlement_crud
  5877. from datetime import datetime
  5878. from decimal import Decimal
  5879. order_id = request.get("orderId")
  5880. settlement_method = request.get("settlementMethod")
  5881. settlement_time_str = request.get("settlementTime")
  5882. note = request.get("settlementNote")
  5883. attachment_url = request.get("attachmentUrl")
  5884. if not order_id:
  5885. return error_response(400, "请选择订单")
  5886. if not settlement_method:
  5887. return error_response(400, "请选择结算方式")
  5888. # 获取订单信息
  5889. from app.models.models import BizOrder
  5890. order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
  5891. if not order:
  5892. return error_response(404, "订单不存在")
  5893. # 计算未付金额
  5894. unpaid_amount = (order.receivable_amount or Decimal("0")) - (order.paid_amount or Decimal("0"))
  5895. if unpaid_amount <= 0:
  5896. return error_response(400, "该订单已无欠款,无需结算")
  5897. # 解析结算时间
  5898. settlement_time = None
  5899. if settlement_time_str:
  5900. try:
  5901. settlement_time = datetime.fromisoformat(settlement_time_str.replace("Z", "+00:00"))
  5902. except:
  5903. settlement_time = datetime.now()
  5904. creator = {
  5905. "user_id": current_user.get("user_id"),
  5906. "real_name": current_user.get("real_name")
  5907. }
  5908. settlement = settlement_crud.create_settlement(
  5909. db,
  5910. order_id=order_id,
  5911. client_id=order.client_id,
  5912. region_id=order.region_id,
  5913. amount=unpaid_amount,
  5914. settlement_method=settlement_method,
  5915. settlement_time=settlement_time,
  5916. attachment_url=attachment_url,
  5917. note=note,
  5918. creator=creator
  5919. )
  5920. return success_response({
  5921. "id": settlement.id,
  5922. "message": "结算成功"
  5923. })
  5924. @router.post("/settlements/batch", response_model=dict)
  5925. def batch_create_settlements(
  5926. request: dict,
  5927. db: Session = Depends(get_db),
  5928. current_user: dict = Depends(require_admin)
  5929. ):
  5930. """批量确认线下结算"""
  5931. from app.crud.settlement import settlement_crud
  5932. from datetime import datetime
  5933. order_ids = request.get("orderIds", [])
  5934. settlement_method = request.get("settlementMethod")
  5935. settlement_time_str = request.get("settlementTime")
  5936. note = request.get("settlementNote")
  5937. attachment_url = request.get("attachmentUrl")
  5938. if not order_ids:
  5939. return error_response(400, "请选择要结算的订单")
  5940. if not settlement_method:
  5941. return error_response(400, "请选择结算方式")
  5942. if len(order_ids) > 50:
  5943. return error_response(400, "单次最多结算50个订单")
  5944. # 解析结算时间
  5945. settlement_time = None
  5946. if settlement_time_str:
  5947. try:
  5948. settlement_time = datetime.fromisoformat(settlement_time_str.replace("Z", "+00:00"))
  5949. except:
  5950. settlement_time = datetime.now()
  5951. creator = {
  5952. "user_id": current_user.get("user_id"),
  5953. "real_name": current_user.get("real_name")
  5954. }
  5955. settlements = settlement_crud.batch_create_settlements(
  5956. db,
  5957. order_ids=order_ids,
  5958. settlement_method=settlement_method,
  5959. settlement_time=settlement_time,
  5960. attachment_url=attachment_url,
  5961. note=note,
  5962. creator=creator
  5963. )
  5964. return success_response({
  5965. "count": len(settlements),
  5966. "message": f"成功结算{len(settlements)}个订单"
  5967. })
  5968. @router.get("/settlements/records", response_model=dict)
  5969. def list_settlement_records(
  5970. page: int = Query(1, ge=1, description="页码"),
  5971. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  5972. regionId: Optional[str] = Query(None, description="区域ID"),
  5973. clientId: Optional[str] = Query(None, description="客户ID"),
  5974. startDate: Optional[str] = Query(None, description="开始日期"),
  5975. endDate: Optional[str] = Query(None, description="结束日期"),
  5976. db: Session = Depends(get_db),
  5977. current_user: dict = Depends(require_admin)
  5978. ):
  5979. """获取结算记录列表"""
  5980. from app.crud.settlement import settlement_crud
  5981. settlements, total = settlement_crud.get_multi_with_filters(
  5982. db,
  5983. region_id=regionId,
  5984. client_id=clientId,
  5985. status="confirmed",
  5986. start_date=startDate,
  5987. end_date=endDate,
  5988. skip=(page - 1) * pageSize,
  5989. limit=pageSize
  5990. )
  5991. region_map = {}
  5992. client_map = {}
  5993. reviewer_map = {}
  5994. result = []
  5995. for settlement in settlements:
  5996. if settlement.region_id and settlement.region_id not in region_map:
  5997. region = region_crud.get(db, settlement.region_id)
  5998. region_map[settlement.region_id] = region.name if region else None
  5999. if settlement.client_id and settlement.client_id not in client_map:
  6000. client = user_crud.get(db, settlement.client_id)
  6001. client_map[settlement.client_id] = client.real_name if client else None
  6002. if settlement.reviewed_by and settlement.reviewed_by not in reviewer_map:
  6003. reviewer = user_crud.get(db, settlement.reviewed_by)
  6004. reviewer_map[settlement.reviewed_by] = reviewer.real_name if reviewer else None
  6005. result.append({
  6006. "id": settlement.id,
  6007. "orderId": settlement.order_id,
  6008. "clientId": settlement.client_id,
  6009. "clientName": client_map.get(settlement.client_id),
  6010. "regionId": settlement.region_id,
  6011. "regionName": region_map.get(settlement.region_id),
  6012. "amount": float(settlement.amount) if settlement.amount else 0,
  6013. "settlementMethod": settlement.settlement_method,
  6014. "settlementTime": str(settlement.settlement_time) if settlement.settlement_time else None,
  6015. "settlementNote": settlement.settlement_note,
  6016. "settlementFileUrl": settlement.settlement_file_url,
  6017. "reviewedBy": settlement.reviewed_by,
  6018. "reviewedByName": reviewer_map.get(settlement.reviewed_by),
  6019. "reviewedTime": str(settlement.reviewed_time) if settlement.reviewed_time else None,
  6020. "createAt": str(settlement.create_at) if settlement.create_at else None
  6021. })
  6022. return success_response({
  6023. "list": result,
  6024. "total": total,
  6025. "page": page,
  6026. "pageSize": pageSize,
  6027. "totalPages": (total + pageSize - 1) // pageSize
  6028. })
  6029. @router.get("/settlements/statistics", response_model=dict)
  6030. def get_settlement_statistics(
  6031. regionId: Optional[str] = Query(None, description="区域ID"),
  6032. startDate: Optional[str] = Query(None, description="开始日期"),
  6033. endDate: Optional[str] = Query(None, description="结束日期"),
  6034. db: Session = Depends(get_db),
  6035. current_user: dict = Depends(require_admin)
  6036. ):
  6037. """获取结算统计数据"""
  6038. from app.crud.settlement import settlement_crud
  6039. # 区域管理员只能查看自己区域的统计数据
  6040. admin_region_id = get_admin_region_filter(db, current_user)
  6041. if admin_region_id and not regionId:
  6042. regionId = admin_region_id
  6043. elif admin_region_id and regionId != admin_region_id:
  6044. return success_response({
  6045. "pendingOrderCount": 0,
  6046. "pendingAmount": 0,
  6047. "settledCount": 0,
  6048. "settledAmount": 0,
  6049. "refundPendingCount": 0
  6050. })
  6051. stats = settlement_crud.get_statistics(
  6052. db,
  6053. region_id=regionId,
  6054. start_date=startDate,
  6055. end_date=endDate
  6056. )
  6057. return success_response(stats)
  6058. # =========================================
  6059. # 退款管理 API (Web端)
  6060. # =========================================
  6061. @router.get("/refunds", response_model=dict)
  6062. def list_refunds(
  6063. page: int = Query(1, ge=1, description="页码"),
  6064. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  6065. regionId: Optional[str] = Query(None, description="区域ID"),
  6066. status: Optional[str] = Query(None, description="状态"),
  6067. orderNo: Optional[str] = Query(None, description="订单号"),
  6068. refundMethod: Optional[str] = Query(None, description="退款方式"),
  6069. startDate: Optional[str] = Query(None, description="开始日期"),
  6070. endDate: Optional[str] = Query(None, description="结束日期"),
  6071. db: Session = Depends(get_db),
  6072. current_user: dict = Depends(require_admin)
  6073. ):
  6074. """获取退款记录列表"""
  6075. from app.crud.settlement import refund_crud
  6076. from app.models.models import BizOrder
  6077. refunds, total = refund_crud.get_multi_with_filters(
  6078. db,
  6079. region_id=regionId,
  6080. order_no=orderNo,
  6081. status=status,
  6082. refund_method=refundMethod,
  6083. start_date=startDate,
  6084. end_date=endDate,
  6085. skip=(page - 1) * pageSize,
  6086. limit=pageSize
  6087. )
  6088. order_map = {}
  6089. result = []
  6090. for refund in refunds:
  6091. if refund.order_id and refund.order_id not in order_map:
  6092. order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
  6093. if order:
  6094. client = user_crud.get(db, order.client_id)
  6095. order_map[refund.order_id] = {
  6096. "orderNo": order.order_no,
  6097. "clientId": order.client_id,
  6098. "clientName": client.real_name if client else None,
  6099. "regionId": order.region_id,
  6100. "totalAmount": float(order.total_amount) if order.total_amount else 0,
  6101. "paidAmount": float(order.paid_amount) if order.paid_amount else 0
  6102. }
  6103. order_info = order_map.get(refund.order_id, {})
  6104. result.append({
  6105. "id": refund.id,
  6106. "orderId": refund.order_id,
  6107. "orderNo": order_info.get("orderNo"),
  6108. "clientId": order_info.get("clientId"),
  6109. "clientName": order_info.get("clientName"),
  6110. "regionId": order_info.get("regionId"),
  6111. "totalAmount": order_info.get("totalAmount"),
  6112. "paidAmount": order_info.get("paidAmount"),
  6113. "refundNo": refund.refund_no,
  6114. "refundAmount": float(refund.refund_amount) if refund.refund_amount else 0,
  6115. "refundMethod": refund.refund_method,
  6116. "refundResult": refund.refund_result,
  6117. "refundTime": str(refund.refund_time) if refund.refund_time else None,
  6118. "refundNoWechat": refund.refund_no_wechat,
  6119. "refundFailReason": refund.refund_fail_reason,
  6120. "status": refund.status,
  6121. "createAt": str(refund.create_at) if refund.create_at else None
  6122. })
  6123. return success_response({
  6124. "list": result,
  6125. "total": total,
  6126. "page": page,
  6127. "pageSize": pageSize,
  6128. "totalPages": (total + pageSize - 1) // pageSize
  6129. })
  6130. @router.get("/refunds/{refund_id}", response_model=dict)
  6131. def get_refund(
  6132. refund_id: str,
  6133. db: Session = Depends(get_db),
  6134. current_user: dict = Depends(require_admin)
  6135. ):
  6136. """获取退款详情"""
  6137. from app.crud.settlement import refund_crud
  6138. from app.models.models import BizOrder
  6139. refund = refund_crud.get(db, refund_id)
  6140. if not refund:
  6141. return error_response(404, "退款记录不存在")
  6142. order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
  6143. client = user_crud.get(db, order.client_id) if order else None
  6144. return success_response({
  6145. "id": refund.id,
  6146. "orderId": refund.order_id,
  6147. "orderNo": order.order_no if order else None,
  6148. "clientId": order.client_id if order else None,
  6149. "clientName": client.real_name if client else None,
  6150. "totalAmount": float(order.total_amount) if order and order.total_amount else 0,
  6151. "paidAmount": float(order.paid_amount) if order and order.paid_amount else 0,
  6152. "refundNo": refund.refund_no,
  6153. "refundAmount": float(refund.refund_amount) if refund.refund_amount else 0,
  6154. "refundMethod": refund.refund_method,
  6155. "refundResult": refund.refund_result,
  6156. "refundTime": str(refund.refund_time) if refund.refund_time else None,
  6157. "refundNoWechat": refund.refund_no_wechat,
  6158. "refundFailReason": refund.refund_fail_reason,
  6159. "status": refund.status,
  6160. "createAt": str(refund.create_at) if refund.create_at else None,
  6161. "createName": refund.create_name
  6162. })
  6163. @router.post("/refunds/wechat-refund/{order_id}", response_model=dict)
  6164. def create_wechat_refund(
  6165. order_id: str,
  6166. request: dict,
  6167. db: Session = Depends(get_db),
  6168. current_user: dict = Depends(require_admin)
  6169. ):
  6170. """微信退款"""
  6171. from app.crud.settlement import refund_crud
  6172. from datetime import datetime
  6173. from decimal import Decimal
  6174. refund_amount = request.get("refundAmount")
  6175. reason = request.get("reason")
  6176. if not refund_amount or refund_amount <= 0:
  6177. return error_response(400, "退款金额必须大于0")
  6178. # 获取订单信息
  6179. from app.models.models import BizOrder
  6180. order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
  6181. if not order:
  6182. return error_response(404, "订单不存在")
  6183. # 检查已支付金额
  6184. paid_amount = float(order.paid_amount) if order.paid_amount else 0
  6185. if refund_amount > paid_amount:
  6186. return error_response(400, "退款金额不能超过已支付金额")
  6187. # 检查是否有处理中的退款
  6188. existing_refunds = refund_crud.get_by_order(db, order_id)
  6189. processing_refund = any(r.status in ["pending", "processing"] for r in existing_refunds)
  6190. if processing_refund:
  6191. return error_response(400, "该订单有退款正在处理中,请勿重复提交")
  6192. creator = {
  6193. "user_id": current_user.get("user_id"),
  6194. "real_name": current_user.get("real_name")
  6195. }
  6196. refund = refund_crud.create_refund(
  6197. db,
  6198. order_id=order_id,
  6199. refund_amount=Decimal(str(refund_amount)),
  6200. refund_method="wechat",
  6201. reason=reason,
  6202. creator=creator
  6203. )
  6204. # 模拟微信退款处理(实际项目中需要调用微信支付API)
  6205. refund_crud.process_refund(
  6206. db,
  6207. refund_id=refund.id,
  6208. refund_result="success",
  6209. refund_no_wechat=f"WXREF{datetime.now().strftime('%Y%m%d%H%M%S')}"
  6210. )
  6211. return success_response({
  6212. "refundId": refund.id,
  6213. "refundNo": refund.refund_no,
  6214. "refundAmount": float(refund.refund_amount),
  6215. "refundResult": "success",
  6216. "refundTime": str(refund.refund_time)
  6217. })
  6218. @router.get("/refunds/{refund_id}/status", response_model=dict)
  6219. def get_refund_status(
  6220. refund_id: str,
  6221. db: Session = Depends(get_db),
  6222. current_user: dict = Depends(require_admin)
  6223. ):
  6224. """查询退款状态"""
  6225. from app.crud.settlement import refund_crud
  6226. refund = refund_crud.get(db, refund_id)
  6227. if not refund:
  6228. return error_response(404, "退款记录不存在")
  6229. return success_response({
  6230. "refundId": refund.id,
  6231. "refundNo": refund.refund_no,
  6232. "refundAmount": float(refund.refund_amount) if refund.refund_amount else 0,
  6233. "refundResult": refund.refund_result,
  6234. "refundTime": str(refund.refund_time) if refund.refund_time else None,
  6235. "refundNoWechat": refund.refund_no_wechat
  6236. })
  6237. @router.post("/refunds/{refund_id}/process", response_model=dict)
  6238. def process_refund(
  6239. refund_id: str,
  6240. request: dict,
  6241. db: Session = Depends(get_db),
  6242. current_user: dict = Depends(require_admin)
  6243. ):
  6244. """处理退款(成功/失败)"""
  6245. from app.crud.settlement import refund_crud
  6246. refund_result = request.get("refundResult")
  6247. refund_no_wechat = request.get("refundNoWechat")
  6248. if not refund_result or refund_result not in ["success", "failed"]:
  6249. return error_response(400, "请提供正确的退款结果")
  6250. refund = refund_crud.process_refund(
  6251. db,
  6252. refund_id=refund_id,
  6253. refund_result=refund_result,
  6254. refund_no_wechat=refund_no_wechat
  6255. )
  6256. if not refund:
  6257. return error_response(404, "退款记录不存在")
  6258. return success_response({
  6259. "refundId": refund.id,
  6260. "refundResult": refund.refund_result,
  6261. "message": "处理成功"
  6262. })
  6263. @router.post("/refunds/{refund_id}/handle-failure", response_model=dict)
  6264. def handle_refund_failure(
  6265. refund_id: str,
  6266. request: dict,
  6267. db: Session = Depends(get_db),
  6268. current_user: dict = Depends(require_admin)
  6269. ):
  6270. """处理退款失败"""
  6271. from app.crud.settlement import refund_crud
  6272. fail_reason = request.get("failReason")
  6273. if not fail_reason:
  6274. return error_response(400, "请提供失败原因")
  6275. refund = refund_crud.handle_refund_failure(
  6276. db,
  6277. refund_id=refund_id,
  6278. fail_reason=fail_reason
  6279. )
  6280. if not refund:
  6281. return error_response(404, "退款记录不存在")
  6282. return success_response({
  6283. "refundId": refund.id,
  6284. "refundResult": refund.refund_result,
  6285. "message": "处理成功"
  6286. })
  6287. @router.get("/refunds/statistics", response_model=dict)
  6288. def get_refund_statistics(
  6289. startDate: Optional[str] = Query(None, description="开始日期"),
  6290. endDate: Optional[str] = Query(None, description="结束日期"),
  6291. db: Session = Depends(get_db),
  6292. current_user: dict = Depends(require_admin)
  6293. ):
  6294. """获取退款统计数据"""
  6295. from app.crud.settlement import refund_crud
  6296. stats = refund_crud.get_refund_statistics(
  6297. db,
  6298. start_date=startDate,
  6299. end_date=endDate
  6300. )
  6301. return success_response(stats)
  6302. # =========================================
  6303. # 配送时效预警 API
  6304. # =========================================
  6305. @router.get("/delivery-warnings", response_model=dict)
  6306. def list_delivery_warnings(
  6307. page: int = Query(1, ge=1, description="页码"),
  6308. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  6309. regionId: Optional[str] = Query(None, description="区域ID"),
  6310. urgencyLevel: Optional[str] = Query(None, description="紧急程度: critical/urgent/normal"),
  6311. db: Session = Depends(get_db),
  6312. current_user: dict = Depends(require_admin)
  6313. ):
  6314. """获取配送时效预警列表"""
  6315. from app.crud.delivery import delivery_task_crud
  6316. from datetime import datetime, timedelta
  6317. # 计算时间阈值
  6318. now = datetime.now()
  6319. critical_time = now + timedelta(minutes=30) # 30分钟内
  6320. urgent_time = now + timedelta(minutes=60) # 1小时内
  6321. normal_time = now + timedelta(hours=2) # 2小时内
  6322. # 获取所有待配送任务
  6323. query = db.query(delivery_task_crud.model).filter(
  6324. and_(
  6325. delivery_task_crud.model.is_del == 0,
  6326. delivery_task_crud.model.status.in_(["pending", "assigned", "picking", "picked"]),
  6327. delivery_task_crud.model.effective_time != None,
  6328. delivery_task_crud.model.effective_time >= now
  6329. )
  6330. )
  6331. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  6332. if allowed_region_ids is not None:
  6333. if regionId and regionId not in allowed_region_ids:
  6334. return error_response(403, "无权访问该区域数据")
  6335. if not regionId and allowed_region_ids:
  6336. regionId = sorted(allowed_region_ids)[0]
  6337. if regionId:
  6338. query = query.filter(delivery_task_crud.model.region_id == regionId)
  6339. all_tasks = query.order_by(delivery_task_crud.model.effective_time.asc()).all()
  6340. # 计算紧急程度
  6341. result = []
  6342. for task in all_tasks:
  6343. time_diff = (task.effective_time - now).total_seconds() / 60 # 分钟
  6344. if time_diff <= 30:
  6345. urgency = "critical"
  6346. remaining_minutes = int(time_diff)
  6347. elif time_diff <= 60:
  6348. urgency = "urgent"
  6349. remaining_minutes = int(time_diff)
  6350. else:
  6351. urgency = "normal"
  6352. remaining_minutes = int(time_diff)
  6353. # 按紧急程度过滤
  6354. if urgencyLevel and urgency != urgencyLevel:
  6355. continue
  6356. # 获取关联信息
  6357. client_name = None
  6358. if task.order_id:
  6359. from app.crud.order import order_crud
  6360. order = order_crud.get(db, task.order_id)
  6361. if order:
  6362. client = user_crud.get(db, order.client_id)
  6363. client_name = client.real_name if client else None
  6364. region_name = None
  6365. if task.region_id:
  6366. region = region_crud.get(db, task.region_id)
  6367. region_name = region.name if region else None
  6368. deliver_name = None
  6369. if task.deliver_id:
  6370. deliver = user_crud.get(db, task.deliver_id)
  6371. deliver_name = deliver.real_name if deliver else None
  6372. supplier_name = None
  6373. if task.supplier_id:
  6374. supplier = user_crud.get(db, task.supplier_id)
  6375. supplier_name = supplier.real_name if supplier else None
  6376. result.append({
  6377. "id": task.id,
  6378. "taskNo": task.task_no,
  6379. "orderId": task.order_id,
  6380. "regionId": task.region_id,
  6381. "regionName": region_name,
  6382. "clientName": client_name,
  6383. "supplierId": task.supplier_id,
  6384. "supplierName": supplier_name,
  6385. "deliverId": task.deliver_id,
  6386. "deliverName": deliver_name,
  6387. "status": task.status,
  6388. "effectiveTime": str(task.effective_time) if task.effective_time else None,
  6389. "remainingMinutes": remaining_minutes,
  6390. "urgencyLevel": urgency,
  6391. "isAssigned": task.deliver_id is not None,
  6392. "createAt": str(task.create_at) if task.create_at else None
  6393. })
  6394. # 手动分页
  6395. total = len(result)
  6396. start_idx = (page - 1) * pageSize
  6397. end_idx = start_idx + pageSize
  6398. paginated_result = result[start_idx:end_idx]
  6399. return success_response({
  6400. "list": paginated_result,
  6401. "total": total,
  6402. "page": page,
  6403. "pageSize": pageSize,
  6404. "totalPages": (total + pageSize - 1) // pageSize
  6405. })
  6406. @router.get("/delivery-warnings/statistics", response_model=dict)
  6407. def get_delivery_warning_statistics(
  6408. regionId: Optional[str] = Query(None, description="区域ID"),
  6409. db: Session = Depends(get_db),
  6410. current_user: dict = Depends(require_admin)
  6411. ):
  6412. """获取配送时效预警统计"""
  6413. from app.crud.delivery import delivery_task_crud
  6414. from datetime import datetime, timedelta
  6415. now = datetime.now()
  6416. # 基础查询
  6417. base_query = db.query(delivery_task_crud.model).filter(
  6418. and_(
  6419. delivery_task_crud.model.is_del == 0,
  6420. delivery_task_crud.model.status.in_(["pending", "assigned", "picking", "picked"]),
  6421. delivery_task_crud.model.effective_time != None,
  6422. delivery_task_crud.model.effective_time >= now
  6423. )
  6424. )
  6425. allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
  6426. if allowed_region_ids is not None:
  6427. if regionId and regionId not in allowed_region_ids:
  6428. return error_response(403, "无权访问该区域数据")
  6429. if not regionId and allowed_region_ids:
  6430. regionId = sorted(allowed_region_ids)[0]
  6431. if regionId:
  6432. base_query = base_query.filter(delivery_task_crud.model.region_id == regionId)
  6433. all_tasks = base_query.all()
  6434. critical_count = 0
  6435. urgent_count = 0
  6436. normal_count = 0
  6437. unassigned_count = 0
  6438. total_pending = len(all_tasks)
  6439. for task in all_tasks:
  6440. time_diff = (task.effective_time - now).total_seconds() / 60
  6441. if time_diff <= 30:
  6442. critical_count += 1
  6443. elif time_diff <= 60:
  6444. urgent_count += 1
  6445. else:
  6446. normal_count += 1
  6447. if not task.deliver_id:
  6448. unassigned_count += 1
  6449. return success_response({
  6450. "totalPending": total_pending,
  6451. "criticalCount": critical_count,
  6452. "urgentCount": urgent_count,
  6453. "normalCount": normal_count,
  6454. "unassignedCount": unassigned_count
  6455. })
  6456. @router.post("/delivery-warnings/{task_id}/reassign", response_model=dict)
  6457. def reassign_delivery_task(
  6458. task_id: str,
  6459. request: dict,
  6460. db: Session = Depends(get_db),
  6461. current_user: dict = Depends(require_admin)
  6462. ):
  6463. """紧急重新分配配送任务"""
  6464. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  6465. if req_err is not None:
  6466. return req_err
  6467. from app.crud.delivery import delivery_task_crud
  6468. new_deliver_id = request.get("deliverId")
  6469. reason = request.get("reason")
  6470. if not new_deliver_id:
  6471. return error_response(400, "请选择新的配送员")
  6472. task = delivery_task_crud.get(db, task_id)
  6473. if not task:
  6474. return error_response(404, "任务不存在")
  6475. if task.status in ["completed", "cancelled"]:
  6476. return error_response(400, "已完成或已取消的任务无法重新分配")
  6477. # 验证新配送员
  6478. new_deliver = user_crud.get(db, new_deliver_id)
  6479. if not new_deliver:
  6480. return error_response(404, "配送员不存在")
  6481. if new_deliver.role != "deliver":
  6482. return error_response(400, "该用户不是配送员")
  6483. old_deliver_id = task.deliver_id
  6484. old_deliver_name = None
  6485. if old_deliver_id:
  6486. old_deliver = user_crud.get(db, old_deliver_id)
  6487. old_deliver_name = old_deliver.real_name if old_deliver else None
  6488. # 更新任务(换配送员:只重置配送员侧待取货,不重置供应商备货进度)
  6489. task.deliver_id = new_deliver_id
  6490. task.update_at = datetime.now()
  6491. task.deliverer_status = "pending_pickup"
  6492. delivery_task_crud._sync_legacy_status_from_dual(task)
  6493. # 如果之前没有分配配送员,设置新的截止时间
  6494. if not old_deliver_id:
  6495. task.effective_time = datetime.now() + timedelta(hours=4)
  6496. db.commit()
  6497. db.refresh(task)
  6498. return success_response({
  6499. "id": task.id,
  6500. "taskNo": task.task_no,
  6501. "newDeliverId": new_deliver_id,
  6502. "newDeliverName": new_deliver.real_name,
  6503. "oldDeliverId": old_deliver_id,
  6504. "oldDeliverName": old_deliver_name,
  6505. "message": "任务重新分配成功"
  6506. })
  6507. def get_client_ip(request: Request) -> str:
  6508. """获取客户端IP地址"""
  6509. forwarded = request.headers.get("X-Forwarded-For")
  6510. if forwarded:
  6511. return forwarded.split(",")[0].strip()
  6512. return request.client.host if request.client else "unknown"
  6513. # =========================================
  6514. # API 请求日志(中间件落库,此处仅查询/删除)
  6515. # =========================================
  6516. @router.get("/api-request-logs", response_model=dict)
  6517. def list_api_request_logs(
  6518. page: int = Query(1, ge=1, description="页码"),
  6519. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  6520. httpMethod: Optional[str] = Query(None, description="HTTP 方法"),
  6521. statusCode: Optional[int] = Query(None, description="HTTP 状态码"),
  6522. pathPrefix: Optional[str] = Query(None, description="路径包含"),
  6523. keyword: Optional[str] = Query(None, description="关键词"),
  6524. startDate: Optional[str] = Query(None, description="开始日期"),
  6525. endDate: Optional[str] = Query(None, description="结束日期"),
  6526. db: Session = Depends(get_db),
  6527. current_user: dict = Depends(require_admin)
  6528. ):
  6529. """API 请求日志列表"""
  6530. from app.crud.api_request_log import api_request_log_crud
  6531. logs, total = api_request_log_crud.get_multi_with_filters(
  6532. db,
  6533. http_method=httpMethod,
  6534. status_code=statusCode,
  6535. path_prefix=pathPrefix,
  6536. keyword=keyword,
  6537. start_date=startDate,
  6538. end_date=endDate,
  6539. skip=(page - 1) * pageSize,
  6540. limit=pageSize,
  6541. )
  6542. return success_response({
  6543. "list": [{
  6544. "id": log.id,
  6545. "httpMethod": log.http_method,
  6546. "path": log.path,
  6547. "queryString": log.query_string,
  6548. "statusCode": log.status_code,
  6549. "durationMs": log.duration_ms,
  6550. "userId": log.user_id,
  6551. "username": log.username,
  6552. "role": log.role,
  6553. "ipAddress": log.ip_address,
  6554. "userAgent": log.user_agent,
  6555. "requestBody": log.request_body,
  6556. "responseBody": log.response_body,
  6557. "createAt": str(log.create_at) if log.create_at else None,
  6558. } for log in logs],
  6559. "total": total,
  6560. "page": page,
  6561. "pageSize": pageSize,
  6562. "totalPages": (total + pageSize - 1) // pageSize
  6563. })
  6564. @router.get("/api-request-logs/{log_id}", response_model=dict)
  6565. def get_api_request_log(
  6566. log_id: str,
  6567. db: Session = Depends(get_db),
  6568. current_user: dict = Depends(require_admin)
  6569. ):
  6570. """API 请求日志详情"""
  6571. from app.crud.api_request_log import api_request_log_crud
  6572. log = api_request_log_crud.get(db, log_id)
  6573. if not log:
  6574. return error_response(404, "日志不存在")
  6575. return success_response({
  6576. "id": log.id,
  6577. "httpMethod": log.http_method,
  6578. "path": log.path,
  6579. "queryString": log.query_string,
  6580. "statusCode": log.status_code,
  6581. "durationMs": log.duration_ms,
  6582. "userId": log.user_id,
  6583. "username": log.username,
  6584. "role": log.role,
  6585. "ipAddress": log.ip_address,
  6586. "userAgent": log.user_agent,
  6587. "requestBody": log.request_body,
  6588. "responseBody": log.response_body,
  6589. "createAt": str(log.create_at) if log.create_at else None,
  6590. })
  6591. @router.delete("/api-request-logs/{log_id}", response_model=dict)
  6592. def delete_api_request_log(
  6593. log_id: str,
  6594. db: Session = Depends(get_db),
  6595. current_user: dict = Depends(require_admin)
  6596. ):
  6597. """删除 API 请求日志(软删除)"""
  6598. from app.crud.api_request_log import api_request_log_crud
  6599. if not api_request_log_crud.delete(db, log_id):
  6600. return error_response(404, "日志不存在")
  6601. return success_response({"id": log_id})
  6602. # =========================================
  6603. # 数据统计 API
  6604. # =========================================
  6605. @router.get("/statistics/dashboard", response_model=dict)
  6606. def get_dashboard_statistics(
  6607. regionId: Optional[str] = Query(None, description="区域ID"),
  6608. db: Session = Depends(get_db),
  6609. current_user: dict = Depends(require_admin)
  6610. ):
  6611. """获取仪表盘统计数据"""
  6612. from app.crud.statistics import statistics_crud
  6613. include_inactive_regions = current_user.get("role") == "super_admin"
  6614. stats = statistics_crud.get_dashboard_summary(
  6615. db,
  6616. region_id=regionId,
  6617. include_inactive_regions=include_inactive_regions,
  6618. )
  6619. return success_response(stats)
  6620. @router.get("/statistics/orders", response_model=dict)
  6621. def get_order_statistics(
  6622. regionId: Optional[str] = Query(None, description="区域ID"),
  6623. startDate: Optional[str] = Query(None, description="开始日期"),
  6624. endDate: Optional[str] = Query(None, description="结束日期"),
  6625. db: Session = Depends(get_db),
  6626. current_user: dict = Depends(require_admin)
  6627. ):
  6628. """获取订单统计数据"""
  6629. from app.crud.statistics import statistics_crud
  6630. stats = statistics_crud.get_order_statistics(
  6631. db,
  6632. region_id=regionId,
  6633. start_date=startDate,
  6634. end_date=endDate
  6635. )
  6636. return success_response(stats)
  6637. @router.get("/statistics/orders/trend", response_model=dict)
  6638. def get_order_trend(
  6639. regionId: Optional[str] = Query(None, description="区域ID"),
  6640. days: int = Query(7, ge=1, le=30, description="统计天数"),
  6641. db: Session = Depends(get_db),
  6642. current_user: dict = Depends(require_admin)
  6643. ):
  6644. """获取订单趋势数据"""
  6645. from app.crud.statistics import statistics_crud
  6646. trend = statistics_crud.get_order_trend(
  6647. db,
  6648. region_id=regionId,
  6649. days=days,
  6650. include_inactive_regions=current_user.get("role") == "super_admin",
  6651. )
  6652. return success_response(trend)
  6653. @router.get("/statistics/orders/today-by-region", response_model=dict)
  6654. def get_today_orders_by_region(
  6655. regionId: Optional[str] = Query(None, description="区域ID,传入时仅返回该区域"),
  6656. db: Session = Depends(get_db),
  6657. current_user: dict = Depends(require_admin)
  6658. ):
  6659. """今日各区域营业额(元)与订单数(按区域列表,无单为 0)"""
  6660. from app.crud.statistics import statistics_crud
  6661. rows = statistics_crud.get_today_order_stats_by_region(
  6662. db,
  6663. region_id=regionId,
  6664. include_inactive_regions=current_user.get("role") == "super_admin",
  6665. )
  6666. return success_response(rows)
  6667. @router.get("/statistics/orders/today-by-client", response_model=dict)
  6668. def get_today_orders_by_client(
  6669. regionId: Optional[str] = Query(None, description="区域ID"),
  6670. limit: int = Query(30, ge=1, le=100, description="最多返回客户条数"),
  6671. db: Session = Depends(get_db),
  6672. current_user: dict = Depends(require_admin)
  6673. ):
  6674. """今日下单客户:下单金额(元)、商品数量合计(按客户,金额降序)"""
  6675. from app.crud.statistics import statistics_crud
  6676. rows = statistics_crud.get_today_order_stats_by_client(
  6677. db,
  6678. region_id=regionId,
  6679. limit=limit,
  6680. include_inactive_regions=current_user.get("role") == "super_admin",
  6681. )
  6682. return success_response(rows)
  6683. @router.get("/statistics/financial", response_model=dict)
  6684. def get_financial_statistics(
  6685. regionId: Optional[str] = Query(None, description="区域ID"),
  6686. startDate: Optional[str] = Query(None, description="开始日期"),
  6687. endDate: Optional[str] = Query(None, description="结束日期"),
  6688. db: Session = Depends(get_db),
  6689. current_user: dict = Depends(require_admin)
  6690. ):
  6691. """获取财务统计数据"""
  6692. from app.crud.statistics import statistics_crud
  6693. stats = statistics_crud.get_financial_statistics(
  6694. db,
  6695. region_id=regionId,
  6696. start_date=startDate,
  6697. end_date=endDate
  6698. )
  6699. return success_response(stats)
  6700. @router.get("/statistics/financial/trend", response_model=dict)
  6701. def get_financial_trend(
  6702. regionId: Optional[str] = Query(None, description="区域ID"),
  6703. days: int = Query(7, ge=1, le=30, description="统计天数"),
  6704. db: Session = Depends(get_db),
  6705. current_user: dict = Depends(require_admin)
  6706. ):
  6707. """获取财务趋势数据"""
  6708. from app.crud.statistics import statistics_crud
  6709. trend = statistics_crud.get_financial_trend(
  6710. db,
  6711. region_id=regionId,
  6712. days=days
  6713. )
  6714. return success_response(trend)
  6715. @router.get("/statistics/delivery", response_model=dict)
  6716. def get_delivery_statistics(
  6717. regionId: Optional[str] = Query(None, description="区域ID"),
  6718. startDate: Optional[str] = Query(None, description="开始日期"),
  6719. endDate: Optional[str] = Query(None, description="结束日期"),
  6720. db: Session = Depends(get_db),
  6721. current_user: dict = Depends(require_admin)
  6722. ):
  6723. """获取配送统计数据"""
  6724. from app.crud.statistics import statistics_crud
  6725. stats = statistics_crud.get_delivery_statistics(
  6726. db,
  6727. region_id=regionId,
  6728. start_date=startDate,
  6729. end_date=endDate
  6730. )
  6731. return success_response(stats)
  6732. @router.get("/statistics/products", response_model=dict)
  6733. def get_product_statistics(
  6734. regionId: Optional[str] = Query(None, description="区域ID"),
  6735. startDate: Optional[str] = Query(None, description="开始日期"),
  6736. endDate: Optional[str] = Query(None, description="结束日期"),
  6737. limit: int = Query(10, ge=1, le=50, description="返回数量"),
  6738. db: Session = Depends(get_db),
  6739. current_user: dict = Depends(require_admin)
  6740. ):
  6741. """获取商品销售排行"""
  6742. from app.crud.statistics import statistics_crud
  6743. stats = statistics_crud.get_product_statistics(
  6744. db,
  6745. region_id=regionId,
  6746. start_date=startDate,
  6747. end_date=endDate,
  6748. limit=limit
  6749. )
  6750. return success_response(stats)
  6751. @router.get("/statistics/customers", response_model=dict)
  6752. def get_customer_statistics(
  6753. regionId: Optional[str] = Query(None, description="区域ID"),
  6754. startDate: Optional[str] = Query(None, description="开始日期"),
  6755. endDate: Optional[str] = Query(None, description="结束日期"),
  6756. limit: int = Query(10, ge=1, le=50, description="返回数量"),
  6757. db: Session = Depends(get_db),
  6758. current_user: dict = Depends(require_admin)
  6759. ):
  6760. """获取客户订单排行"""
  6761. from app.crud.statistics import statistics_crud
  6762. stats = statistics_crud.get_customer_statistics(
  6763. db,
  6764. region_id=regionId,
  6765. start_date=startDate,
  6766. end_date=endDate,
  6767. limit=limit
  6768. )
  6769. return success_response(stats)
  6770. # =========================================
  6771. # 缺失的订单相关 API
  6772. # =========================================
  6773. @router.post("/orders/{order_id}/complete", response_model=dict)
  6774. def force_complete_order(
  6775. order_id: str,
  6776. db: Session = Depends(get_db),
  6777. current_user: dict = Depends(require_admin)
  6778. ):
  6779. """完结订单"""
  6780. from app.crud.order import order_crud
  6781. order = order_crud.get(db, order_id)
  6782. if not order:
  6783. return error_response(404, "订单不存在")
  6784. lock_err = _ensure_order_write_lock(db, order, current_user.get("user_id"))
  6785. if lock_err is not None:
  6786. return lock_err
  6787. # 新状态模型:待收货阶段可由运营强制完结
  6788. if getattr(order, "order_status", None) != "pending_confirm":
  6789. return error_response(400, "只有待收货订单才能完结")
  6790. try:
  6791. completed_order = order_crud.force_complete(db, order_id, current_user.get("user_id"))
  6792. return success_response({
  6793. "id": completed_order.id,
  6794. "status": completed_order.status,
  6795. "message": "订单已完结"
  6796. })
  6797. except Exception as e:
  6798. return error_response(400, str(e))
  6799. @router.post("/orders/{order_id}/cancel", response_model=dict)
  6800. def cancel_order_admin(
  6801. order_id: str,
  6802. request: dict,
  6803. db: Session = Depends(get_db),
  6804. current_user: dict = Depends(require_admin)
  6805. ):
  6806. """运营端取消订单"""
  6807. request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  6808. if req_err is not None:
  6809. return req_err
  6810. from app.crud.order import order_crud
  6811. order = order_crud.get(db, order_id)
  6812. if not order:
  6813. return error_response(404, "订单不存在")
  6814. lock_err = _ensure_order_write_lock(db, order, current_user.get("user_id"))
  6815. if lock_err is not None:
  6816. return lock_err
  6817. # 新状态模型:仅未完成且未取消订单允许运营取消
  6818. if getattr(order, "order_status", None) in ["completed", "cancelled"]:
  6819. return error_response(400, "已完成或已取消订单不能再次取消")
  6820. reason = request.get("reason", "")
  6821. note = request.get("note", "")
  6822. evidence_image_urls = request.get("evidenceImageUrls", []) or []
  6823. if not reason:
  6824. return error_response(400, "请填写取消原因")
  6825. if not isinstance(evidence_image_urls, list):
  6826. return error_response(400, "evidenceImageUrls 格式不正确")
  6827. try:
  6828. cancelled_order = order_crud.admin_cancel(
  6829. db,
  6830. order_id=order_id,
  6831. operator_id=current_user.get("user_id"),
  6832. operator_name=current_user.get("real_name"),
  6833. reason=reason,
  6834. note=note,
  6835. evidence_image_urls=evidence_image_urls,
  6836. )
  6837. if evidence_image_urls:
  6838. from app.models.models import BizAttachment
  6839. from app.core.utils import generate_id
  6840. for url in evidence_image_urls:
  6841. if not isinstance(url, str) or not url.strip():
  6842. continue
  6843. db.add(BizAttachment(
  6844. id=generate_id(),
  6845. business_type="order",
  6846. business_id=order_id,
  6847. file_name=url.split("/")[-1][:200],
  6848. file_url=url,
  6849. file_type="image/*",
  6850. upload_by=current_user.get("user_id"),
  6851. upload_by_name=current_user.get("real_name"),
  6852. create_by=current_user.get("user_id"),
  6853. create_name=current_user.get("real_name"),
  6854. ))
  6855. db.commit()
  6856. return success_response({
  6857. "id": cancelled_order.id,
  6858. "status": cancelled_order.status,
  6859. "message": "订单已取消"
  6860. })
  6861. except Exception as e:
  6862. return error_response(400, str(e))
  6863. @router.get("/orders/adjustable", response_model=dict)
  6864. def list_adjustable_orders(
  6865. keyword: Optional[str] = Query(None, description="关键词搜索"),
  6866. db: Session = Depends(get_db),
  6867. current_user: dict = Depends(require_admin)
  6868. ):
  6869. """获取可调整的订单列表"""
  6870. from app.crud.order import order_crud, order_item_crud
  6871. # 可调整的订单状态:待支付、待收货(线下单)
  6872. adjustable_statuses = ["pending_payment", "pending_confirm"]
  6873. orders, total = order_crud.get_multi_with_filters(
  6874. db,
  6875. status=None, # 不按状态过滤,我们手动过滤
  6876. order_no=keyword,
  6877. skip=0,
  6878. limit=100 # 默认获取100条
  6879. )
  6880. # 过滤出可调整的订单
  6881. adjustable_orders = []
  6882. for order in orders:
  6883. if getattr(order, "order_status", None) in adjustable_statuses and order_crud.normalize_payment_method(order.payment_method) == "offline":
  6884. item_count = order_item_crud.get_count_by_order(db, order.id)
  6885. adjustable_orders.append({
  6886. "id": order.id,
  6887. "orderNo": order.order_no,
  6888. "status": order.status,
  6889. "orderStatus": getattr(order, "order_status", None) or "pending_confirm",
  6890. "deliveryStatus": getattr(order, "delivery_status", None) or "pending_dispatch",
  6891. "payStatus": getattr(order, "pay_status", None) or "pending_payment",
  6892. "statusText": ORDER_STATUS_MAP.get(order.status, order.status),
  6893. "totalAmount": float(order.total_amount) if order.total_amount else 0,
  6894. "itemCount": item_count,
  6895. "createAt": str(order.create_at) if order.create_at else None
  6896. })
  6897. return success_response({
  6898. "list": adjustable_orders,
  6899. "total": len(adjustable_orders)
  6900. })
  6901. @router.get("/orders/{order_id}/items", response_model=dict)
  6902. def get_order_items(
  6903. order_id: str,
  6904. db: Session = Depends(get_db),
  6905. current_user: dict = Depends(require_admin)
  6906. ):
  6907. """获取订单商品列表"""
  6908. from app.crud.order import order_crud, order_item_crud
  6909. order = order_crud.get(db, order_id)
  6910. if not order:
  6911. return error_response(404, "订单不存在")
  6912. items = order_item_crud.get_by_order(db, order_id)
  6913. from app.crud.price_list import price_list_crud
  6914. price_list = price_list_crud.get(db, order.price_list_id) if order.price_list_id else None
  6915. supplier_id_from_price_list = getattr(price_list, "supplier_id", None) if price_list else None
  6916. supplier_name_from_price_list = None
  6917. if supplier_id_from_price_list:
  6918. _sup_u = user_crud.get(db, supplier_id_from_price_list)
  6919. if _sup_u:
  6920. supplier_name_from_price_list = _sup_u.real_name or _sup_u.username
  6921. pl_ids_for_order = [order.price_list_id] if order.price_list_id else []
  6922. pli_map_for_order = _price_list_item_map(db, pl_ids_for_order)
  6923. from app.crud.delivery import delivery_task_item_crud as _merge_task_delivered_items
  6924. _line_delivered_from_task = _merge_task_delivered_items.get_delivered_quantity_by_order_item(db, order_id)
  6925. product_ids = [str(item.product_id) for item in items if item.product_id]
  6926. extra_unit_ids = [str(item.unit_id) for item in items if item.unit_id]
  6927. product_map, category_map, unit_map = _prefetch_products_categories_units(
  6928. db, product_ids, extra_unit_ids
  6929. )
  6930. result = []
  6931. for item in items:
  6932. product = product_map.get(str(item.product_id or ""))
  6933. unit_name = unit_map.get(str(item.unit_id)) if item.unit_id else None
  6934. category_id = str(product.category_id) if product and product.category_id else None
  6935. category_name = category_map.get(category_id) if category_id else None
  6936. _oi_dq = float(item.delivered_quantity) if item.delivered_quantity else 0.0
  6937. _ti_dq = float(_line_delivered_from_task.get(item.id, 0.0))
  6938. _merged_dq = max(_oi_dq, _ti_dq)
  6939. qty = float(item.quantity) if item.quantity else 0
  6940. selling_u = float(item.unit_price) if item.unit_price else 0
  6941. selling_amt = float(item.amount) if item.amount else round(qty * selling_u, 2)
  6942. pli = pli_map_for_order.get((order.price_list_id, item.product_id, item.unit_id))
  6943. supplier_u = _supplier_unit_yuan_from_pli(pli)
  6944. if supplier_u is None:
  6945. supplier_u = selling_u
  6946. supplier_amt = round(qty * supplier_u, 2)
  6947. result.append({
  6948. "id": item.id,
  6949. "productId": item.product_id,
  6950. "productName": product.name if product else None,
  6951. "imageUrl": getattr(product, "image_url", None) if product else None,
  6952. "categoryName": category_name,
  6953. "unitId": item.unit_id,
  6954. "unitName": unit_name,
  6955. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
  6956. "spec": getattr(product, "spec", None) if product else None,
  6957. "supplierId": supplier_id_from_price_list,
  6958. "supplierName": supplier_name_from_price_list,
  6959. "quantity": qty,
  6960. "unitPrice": selling_u,
  6961. "amount": selling_amt,
  6962. "sellingUnitPrice": selling_u,
  6963. "sellingAmount": selling_amt,
  6964. "supplierUnitPrice": supplier_u,
  6965. "supplierAmount": supplier_amt,
  6966. "deliveredQuantity": _merged_dq,
  6967. "status": item.status
  6968. })
  6969. return success_response({
  6970. "list": result,
  6971. "total": len(result)
  6972. })
  6973. # =========================================
  6974. # 商品简单列表 API
  6975. # =========================================
  6976. @router.get("/products/simple", response_model=dict)
  6977. def list_products_simple(
  6978. keyword: Optional[str] = Query(None, description="关键词搜索"),
  6979. categoryId: Optional[str] = Query(None, description="分类ID"),
  6980. db: Session = Depends(get_db),
  6981. current_user: dict = Depends(require_admin)
  6982. ):
  6983. """获取商品简单列表(用于订单调整选择替代商品)"""
  6984. from app.models.models import BizProduct
  6985. query = db.query(BizProduct).filter(BizProduct.is_del == 0, BizProduct.is_active == 1)
  6986. if keyword:
  6987. query = query.filter(BizProduct.name.ilike(f"%{keyword}%"))
  6988. if categoryId:
  6989. query = query.filter(BizProduct.category_id == categoryId)
  6990. products = query.order_by(BizProduct.name).limit(100).all()
  6991. category_ids = list({str(p.category_id) for p in products if p.category_id})
  6992. unit_ids = list({str(p.unit_id) for p in products if p.unit_id})
  6993. category_map: dict = {}
  6994. unit_map: dict = {}
  6995. if category_ids:
  6996. cats = db.query(BasProductCategory).filter(
  6997. and_(BasProductCategory.id.in_(category_ids), BasProductCategory.is_del == 0)
  6998. ).all()
  6999. category_map = {str(c.id): str(c.name or "") for c in cats}
  7000. if unit_ids:
  7001. units = db.query(BasProductUnit).filter(
  7002. and_(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0)
  7003. ).all()
  7004. unit_map = {str(u.id): str(u.name or "") for u in units}
  7005. result = []
  7006. for p in products:
  7007. category_name = category_map.get(str(p.category_id)) if p.category_id else None
  7008. unit_name = unit_map.get(str(p.unit_id)) if p.unit_id else None
  7009. result.append({
  7010. "id": p.id,
  7011. "name": p.name,
  7012. "categoryId": p.category_id,
  7013. "categoryName": category_name,
  7014. "unitId": p.unit_id,
  7015. "unitName": unit_name,
  7016. "imageUrl": p.image_url
  7017. })
  7018. return success_response({
  7019. "list": result,
  7020. "total": len(result)
  7021. })
  7022. # =========================================
  7023. # 询价单简单列表 API
  7024. # =========================================
  7025. @router.get("/inquiries/simple", response_model=dict)
  7026. def list_inquiries_simple(
  7027. status: Optional[str] = Query(None, description="状态"),
  7028. db: Session = Depends(get_db),
  7029. current_user: dict = Depends(require_admin)
  7030. ):
  7031. """获取询价单简单列表(用于报价对比选择)"""
  7032. from app.models.models import BizInquiry
  7033. query = db.query(BizInquiry).filter(BizInquiry.is_del == 0)
  7034. if status:
  7035. query = query.filter(BizInquiry.status == status)
  7036. inquiries = query.order_by(BizInquiry.create_at.desc()).limit(50).all()
  7037. result = []
  7038. for inquiry in inquiries:
  7039. region_name = None
  7040. if inquiry.region_id:
  7041. region = region_crud.get(db, inquiry.region_id)
  7042. region_name = region.name if region else None
  7043. result.append({
  7044. "id": inquiry.id,
  7045. "title": inquiry.title,
  7046. "status": inquiry.status,
  7047. "regionId": inquiry.region_id,
  7048. "regionName": region_name,
  7049. "createAt": str(inquiry.create_at) if inquiry.create_at else None
  7050. })
  7051. return success_response({
  7052. "list": result,
  7053. "total": len(result)
  7054. })
  7055. # =========================================
  7056. # 报价确认供应商 API
  7057. # =========================================
  7058. @router.post("/quotations/confirm-supplier", response_model=dict)
  7059. def confirm_quotation_supplier(
  7060. request: dict,
  7061. db: Session = Depends(get_db),
  7062. current_user: dict = Depends(require_admin)
  7063. ):
  7064. """确认选择供应商报价,并生成区域生效价格清单(active)
  7065. 约束:
  7066. - 同一询价单,仅允许 1 个报价单为 selected
  7067. - 报价单状态仅使用:submitted / selected
  7068. - 不改变询价单状态(仍为 published 等业务状态)
  7069. """
  7070. from app.models.models import BizInquiry, BizQuotation, BizPriceList, BizPriceListItem
  7071. from app.crud.quotation import quotation_crud, quotation_item_crud
  7072. from app.crud.inquiry import inquiry_item_crud
  7073. from app.crud.product import product_crud
  7074. from app.core.utils import generate_id
  7075. from sqlalchemy import and_
  7076. from decimal import Decimal
  7077. payload, payload_error = _resolve_admin_payload_with_crypto_fallback(request, current_user)
  7078. if payload_error:
  7079. return payload_error
  7080. inquiry_id = payload.get("inquiryId")
  7081. quotation_id = payload.get("quotationId")
  7082. final_price_items = payload.get("finalPriceItems") or []
  7083. if not inquiry_id or not quotation_id:
  7084. return error_response(400, "询价单ID和报价ID不能为空")
  7085. inquiry = db.query(BizInquiry).filter(and_(BizInquiry.id == inquiry_id, BizInquiry.is_del == 0)).first()
  7086. if not inquiry:
  7087. return error_response(404, "询价单不存在")
  7088. quotation = quotation_crud.get(db, quotation_id)
  7089. if not quotation:
  7090. return error_response(404, "报价不存在")
  7091. if quotation.inquiry_id != inquiry_id:
  7092. return error_response(400, "报价不属于该询价单")
  7093. # 询价单必须处于发布中,才允许选择供应商生成生效价格
  7094. if inquiry.status != "published":
  7095. return error_response(400, "只能对已发布状态的询价单选择供应商报价")
  7096. # 同一询价仅允许一个 selected:先将其它报价恢复为 submitted
  7097. db.query(BizQuotation).filter(
  7098. and_(
  7099. BizQuotation.inquiry_id == inquiry_id,
  7100. BizQuotation.id != quotation_id,
  7101. BizQuotation.is_del == 0
  7102. )
  7103. ).update({"status": "submitted"}, synchronize_session=False)
  7104. # 当前报价置为 selected
  7105. quotation.status = "selected"
  7106. # 生成价格清单:范围仅询价单的商品
  7107. inquiry_items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
  7108. inquiry_product_ids = {i.product_id for i in inquiry_items if i and i.product_id}
  7109. if not inquiry_product_ids:
  7110. return error_response(400, "询价单必须包含至少一个商品")
  7111. quotation_items = quotation_item_crud.get_by_quotation(db, quotation_id)
  7112. quotation_item_by_pid: dict[str, any] = {}
  7113. for qi in quotation_items or []:
  7114. pid = str(getattr(qi, "product_id", "") or "")
  7115. if not pid:
  7116. continue
  7117. if pid in inquiry_product_ids:
  7118. quotation_item_by_pid[pid] = qi
  7119. # 允许供应商某些商品未报价:只要运营端传入了最终售价(finalPriceItems)即可生成价格清单
  7120. if not quotation_item_by_pid and not final_price_items:
  7121. return error_response(400, "该报价单未包含本询价单商品报价,且未传入最终售价,无法生成价格清单")
  7122. # 计算版本号(区域维度自增)
  7123. max_version_row = db.query(BizPriceList).filter(
  7124. and_(BizPriceList.region_id == inquiry.region_id, BizPriceList.is_del == 0)
  7125. ).order_by(BizPriceList.version.desc()).first()
  7126. next_version = (max_version_row.version + 1) if max_version_row and max_version_row.version else 1
  7127. # 将该区域其他 active 价格清单置为 inactive
  7128. db.query(BizPriceList).filter(
  7129. and_(
  7130. BizPriceList.region_id == inquiry.region_id,
  7131. BizPriceList.status == "active",
  7132. BizPriceList.is_del == 0
  7133. )
  7134. ).update({"status": "inactive"}, synchronize_session=False)
  7135. # 创建新价格清单(直接生效)
  7136. price_list_id = generate_id()
  7137. now = datetime.now()
  7138. price_list = BizPriceList(
  7139. id=price_list_id,
  7140. region_id=inquiry.region_id,
  7141. supplier_id=quotation.supplier_id,
  7142. inquiry_id=inquiry_id,
  7143. quotation_id=quotation_id,
  7144. source_type="quotation_select",
  7145. version=next_version,
  7146. status="active",
  7147. effective_time=now,
  7148. published_by=current_user.get("user_id"),
  7149. published_time=now,
  7150. remark=f"由询价单选择供应商报价生成(询价={inquiry_id}, 报价={quotation_id})",
  7151. create_by=current_user.get("user_id"),
  7152. create_name=current_user.get("real_name")
  7153. )
  7154. db.add(price_list)
  7155. db.flush()
  7156. # 可选:前端传入最终售卖价 + 本次调价系数(用于回显与二次发布)
  7157. # key 统一使用 str(product_id),避免类型不一致导致匹配失败
  7158. final_price_map: dict[str, Decimal] = {}
  7159. pricing_coeff_map: dict[str, Decimal] = {}
  7160. if final_price_items:
  7161. if not isinstance(final_price_items, list):
  7162. return error_response(400, "finalPriceItems 参数格式错误")
  7163. for it in final_price_items:
  7164. if not isinstance(it, dict):
  7165. return error_response(400, "finalPriceItems 参数格式错误")
  7166. pid = it.get("productId")
  7167. price = it.get("price")
  7168. pricing_coeff = it.get("pricingCoefficient")
  7169. if not pid:
  7170. return error_response(400, "finalPriceItems.productId 不能为空")
  7171. pid_key = str(pid)
  7172. try:
  7173. price_decimal = Decimal(str(price))
  7174. except Exception:
  7175. return error_response(400, f"商品 {pid} 的价格格式不正确")
  7176. if price_decimal <= Decimal("0"):
  7177. return error_response(400, f"商品 {pid} 的价格必须大于0")
  7178. final_price_map[pid_key] = price_decimal.quantize(Decimal("0.01"))
  7179. if pricing_coeff is not None and pricing_coeff != "":
  7180. try:
  7181. coeff_decimal = Decimal(str(pricing_coeff))
  7182. except Exception:
  7183. return error_response(400, f"商品 {pid} 的调价系数格式不正确")
  7184. pricing_coeff_map[pid_key] = coeff_decimal.quantize(Decimal("0.01"))
  7185. # 写入价格清单明细:范围=询价单的商品(不要求供应商都报价)
  7186. for ii in inquiry_items:
  7187. pid_key = str(getattr(ii, "product_id", "") or "")
  7188. if not pid_key:
  7189. continue
  7190. qi = quotation_item_by_pid.get(pid_key)
  7191. final_price = final_price_map.get(pid_key)
  7192. pricing_coeff = pricing_coeff_map.get(pid_key)
  7193. # 最终售价优先取前端传入;否则回退为供应商报价
  7194. if final_price is None:
  7195. if qi is not None and getattr(qi, "price", None) is not None:
  7196. final_price = Decimal(str(qi.price)).quantize(Decimal("0.01"))
  7197. else:
  7198. return error_response(400, f"商品 {pid_key} 缺少供应商报价,且未传入最终售价,无法生成价格清单")
  7199. if final_price <= Decimal("0"):
  7200. return error_response(400, f"商品 {pid_key} 的价格必须大于0")
  7201. # 单位ID:优先报价单项;否则从商品表取
  7202. unit_id = getattr(qi, "unit_id", None) if qi is not None else None
  7203. if not unit_id:
  7204. product_for_unit = product_crud.get(db, pid_key)
  7205. unit_id = getattr(product_for_unit, "unit_id", None) if product_for_unit else None
  7206. if not unit_id:
  7207. return error_response(400, f"商品 {pid_key} 缺少单位信息,无法生成价格清单")
  7208. # 若前端未传调价系数:尝试通过(最终价, 供应商报价)反推;再兜底商品默认系数
  7209. if pricing_coeff is None:
  7210. if qi is not None and getattr(qi, "price", None) is not None:
  7211. try:
  7212. supplier_price_decimal = Decimal(str(qi.price))
  7213. except Exception:
  7214. supplier_price_decimal = Decimal("0")
  7215. if supplier_price_decimal > Decimal("0"):
  7216. try:
  7217. pricing_coeff = ((final_price / supplier_price_decimal) - Decimal("1")) * Decimal("100")
  7218. pricing_coeff = pricing_coeff.quantize(Decimal("0.01"))
  7219. except Exception:
  7220. pricing_coeff = None
  7221. if pricing_coeff is None:
  7222. product = product_crud.get(db, pid_key)
  7223. raw_default = getattr(product, "price_coefficient", None) if product else None
  7224. try:
  7225. pricing_coeff = Decimal(str(raw_default)).quantize(Decimal("0.01")) if raw_default is not None else Decimal("0.00")
  7226. except Exception:
  7227. pricing_coeff = Decimal("0.00")
  7228. pli = BizPriceListItem(
  7229. id=generate_id(),
  7230. price_list_id=price_list_id,
  7231. product_id=pid_key,
  7232. unit_id=unit_id,
  7233. price=final_price,
  7234. pricing_coefficient=pricing_coeff,
  7235. create_by=current_user.get("user_id"),
  7236. create_name=current_user.get("real_name")
  7237. )
  7238. db.add(pli)
  7239. db.commit()
  7240. # 广播:该区域生效价格已更新(小程序端可实时刷新)
  7241. try:
  7242. from app.ws.price_updates import notify_price_list_updated, PriceListUpdatedEvent
  7243. logger.warning(
  7244. "[ws-mini] trigger confirm_supplier region=%s price_list_id=%s version=%s",
  7245. inquiry.region_id,
  7246. price_list_id,
  7247. next_version,
  7248. )
  7249. notify_price_list_updated(PriceListUpdatedEvent(
  7250. regionId=inquiry.region_id,
  7251. priceListId=price_list_id,
  7252. priceVersion=next_version,
  7253. publishedTime=str(now),
  7254. ))
  7255. except Exception:
  7256. # 推送失败不影响主流程
  7257. logger.exception(
  7258. "[ws-mini] notify failed in confirm_supplier inquiry_id=%s quotation_id=%s",
  7259. inquiry_id,
  7260. quotation_id,
  7261. )
  7262. return success_response({
  7263. "message": "已选择供应商报价并生成生效价格",
  7264. "inquiryId": inquiry_id,
  7265. "quotationId": quotation_id,
  7266. "priceListId": price_list_id,
  7267. "priceVersion": next_version
  7268. })
  7269. # =========================================
  7270. # 价格版本管理 API
  7271. # =========================================
  7272. @router.get("/price-versions", response_model=dict)
  7273. def list_price_versions(
  7274. page: int = Query(1, ge=1, description="页码"),
  7275. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  7276. regionId: Optional[str] = Query(None, description="区域ID"),
  7277. supplierId: Optional[str] = Query(None, description="供应商ID"),
  7278. status: Optional[str] = Query(None, description="状态"),
  7279. db: Session = Depends(get_db),
  7280. current_user: dict = Depends(require_admin)
  7281. ):
  7282. """获取价格版本列表"""
  7283. from app.models.models import BizPriceList, SysRegion, SysUser
  7284. from sqlalchemy import func
  7285. query = db.query(BizPriceList).filter(BizPriceList.is_del == 0)
  7286. if regionId:
  7287. query = query.filter(BizPriceList.region_id == regionId)
  7288. if supplierId:
  7289. query = query.filter(BizPriceList.supplier_id == supplierId)
  7290. if status:
  7291. query = query.filter(BizPriceList.status == status)
  7292. total = int(query.with_entities(func.count(BizPriceList.id)).order_by(None).scalar() or 0)
  7293. versions = (
  7294. query
  7295. .with_entities(
  7296. BizPriceList.id,
  7297. BizPriceList.version,
  7298. BizPriceList.region_id,
  7299. BizPriceList.supplier_id,
  7300. BizPriceList.status,
  7301. BizPriceList.effective_time,
  7302. BizPriceList.remark,
  7303. BizPriceList.create_at,
  7304. )
  7305. .order_by(BizPriceList.create_at.desc())
  7306. .offset((page - 1) * pageSize)
  7307. .limit(pageSize)
  7308. .all()
  7309. )
  7310. region_ids = list({v.region_id for v in versions if getattr(v, "region_id", None)})
  7311. supplier_ids = list({v.supplier_id for v in versions if getattr(v, "supplier_id", None)})
  7312. region_map = {}
  7313. if region_ids:
  7314. regions = db.query(SysRegion.id, SysRegion.name).filter(SysRegion.id.in_(region_ids), SysRegion.is_del == 0).all()
  7315. region_map = {r.id: r.name for r in regions}
  7316. supplier_map = {}
  7317. if supplier_ids:
  7318. suppliers = db.query(SysUser.id, SysUser.real_name).filter(SysUser.id.in_(supplier_ids), SysUser.is_del == 0).all()
  7319. supplier_map = {u.id: u.real_name for u in suppliers}
  7320. result = []
  7321. for v in versions:
  7322. result.append({
  7323. "id": v.id,
  7324. "versionNo": getattr(v, "version_no", None) or getattr(v, "version", None),
  7325. "regionId": v.region_id,
  7326. "regionName": region_map.get(v.region_id),
  7327. "supplierId": v.supplier_id,
  7328. "supplierName": supplier_map.get(v.supplier_id),
  7329. "status": v.status,
  7330. "effectiveTime": str(v.effective_time) if v.effective_time else None,
  7331. "remark": v.remark,
  7332. "createAt": str(v.create_at) if v.create_at else None
  7333. })
  7334. return success_response({
  7335. "list": result,
  7336. "total": total,
  7337. "page": page,
  7338. "pageSize": pageSize,
  7339. "totalPages": (total + pageSize - 1) // pageSize
  7340. })
  7341. @router.get("/price-versions/{version_id}", response_model=dict)
  7342. def get_price_version_detail(
  7343. version_id: str,
  7344. db: Session = Depends(get_db),
  7345. current_user: dict = Depends(require_admin)
  7346. ):
  7347. """获取价格版本详情(含供应商报价与最终售价拆解,便于订单侧只读查看)。"""
  7348. from app.models.models import BizPriceList, BizPriceListItem, BizQuotationItem
  7349. from app.crud.inquiry import inquiry_crud
  7350. version = db.query(BizPriceList).filter(BizPriceList.id == version_id, BizPriceList.is_del == 0).first()
  7351. if not version:
  7352. return error_response(404, "价格清单不存在")
  7353. region_name = None
  7354. if version.region_id:
  7355. region = region_crud.get(db, version.region_id)
  7356. region_name = region.name if region else None
  7357. supplier_name = None
  7358. if version.supplier_id:
  7359. supplier = user_crud.get(db, version.supplier_id)
  7360. supplier_name = supplier.real_name if supplier else None
  7361. inquiry_title = None
  7362. if version.inquiry_id:
  7363. iq = inquiry_crud.get(db, version.inquiry_id)
  7364. inquiry_title = iq.title if iq else None
  7365. # 来源报价单:用于还原供应商侧单价
  7366. q_item_by_pid: dict[str, object] = {}
  7367. if version.quotation_id:
  7368. q_rows = db.query(BizQuotationItem).filter(
  7369. and_(
  7370. BizQuotationItem.quotation_id == version.quotation_id,
  7371. BizQuotationItem.is_del == 0
  7372. )
  7373. ).all()
  7374. for qi in q_rows:
  7375. pid = str(getattr(qi, "product_id", "") or "")
  7376. if pid and pid not in q_item_by_pid:
  7377. q_item_by_pid[pid] = qi
  7378. # 获取版本明细
  7379. items = db.query(BizPriceListItem).filter(
  7380. BizPriceListItem.price_list_id == version_id,
  7381. BizPriceListItem.is_del == 0
  7382. ).all()
  7383. item_details = []
  7384. product_ids = [str(item.product_id) for item in items if item.product_id]
  7385. extra_unit_ids = [str(item.unit_id) for item in items if item.unit_id]
  7386. product_map, _cat_map, unit_map = _prefetch_products_categories_units(
  7387. db, product_ids, extra_unit_ids
  7388. )
  7389. category_path_map = _build_category_path_map(
  7390. db,
  7391. [
  7392. str(p.category_id)
  7393. for p in product_map.values()
  7394. if getattr(p, "category_id", None)
  7395. ],
  7396. )
  7397. for item in items:
  7398. product = product_map.get(str(item.product_id or ""))
  7399. unit_name = unit_map.get(str(item.unit_id)) if item.unit_id else None
  7400. pid = str(item.product_id or "")
  7401. q_item = q_item_by_pid.get(pid)
  7402. supplier_price = float(getattr(q_item, "price", 0) or 0) if q_item else 0.0
  7403. coeff = None
  7404. if getattr(item, "pricing_coefficient", None) is not None:
  7405. coeff = float(item.pricing_coefficient)
  7406. elif product and getattr(product, "price_coefficient", None) is not None:
  7407. coeff = float(product.price_coefficient)
  7408. else:
  7409. coeff = 0.0
  7410. final_price = float(item.price) if item.price else 0.0
  7411. category_id = getattr(product, "category_id", None) if product else None
  7412. category_path = category_path_map.get(str(category_id), "") if category_id else ""
  7413. item_details.append({
  7414. "id": item.id,
  7415. "productId": item.product_id,
  7416. "productName": product.name if product else None,
  7417. "imageUrl": getattr(product, "image_url", None) if product else None,
  7418. "categoryId": str(category_id) if category_id else None,
  7419. "categoryName": category_path or None,
  7420. "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
  7421. "spec": getattr(product, "spec", None) if product else None,
  7422. "unitId": item.unit_id,
  7423. "unitName": unit_name,
  7424. "supplierPrice": supplier_price,
  7425. "pricingCoefficient": coeff,
  7426. "finalPrice": final_price,
  7427. "price": final_price
  7428. })
  7429. return success_response({
  7430. "id": version.id,
  7431. "versionNo": getattr(version, "version_no", None) or getattr(version, "version", None),
  7432. "version": getattr(version, "version", None),
  7433. "regionId": version.region_id,
  7434. "regionName": region_name,
  7435. "supplierId": version.supplier_id,
  7436. "supplierName": supplier_name,
  7437. "inquiryId": version.inquiry_id,
  7438. "inquiryTitle": inquiry_title,
  7439. "quotationId": version.quotation_id,
  7440. "status": version.status,
  7441. "effectiveTime": str(version.effective_time) if version.effective_time else None,
  7442. "remark": version.remark,
  7443. "createAt": str(version.create_at) if version.create_at else None,
  7444. "items": item_details
  7445. })
  7446. @router.post("/price-versions", response_model=dict)
  7447. def create_price_version(
  7448. request: dict,
  7449. db: Session = Depends(get_db),
  7450. current_user: dict = Depends(require_admin)
  7451. ):
  7452. """创建价格版本"""
  7453. from app.models.models import BizPriceList
  7454. from app.core.utils import generate_uuid
  7455. region_id = request.get("regionId")
  7456. supplier_id = request.get("supplierId")
  7457. effective_time = request.get("effectiveTime")
  7458. remark = request.get("remark")
  7459. if not region_id or not supplier_id:
  7460. return error_response(400, "区域ID和供应商ID不能为空")
  7461. # 生成版本号
  7462. today = datetime.now().strftime("%Y%m%d")
  7463. count = db.query(BizPriceList).filter(
  7464. BizPriceList.region_id == region_id,
  7465. BizPriceList.supplier_id == supplier_id
  7466. ).count()
  7467. version_no = f"PL{today}{count + 1:04d}"
  7468. # 解析生效时间
  7469. effective_dt = None
  7470. if effective_time:
  7471. try:
  7472. effective_dt = datetime.strptime(effective_time, "%Y-%m-%d %H:%M:%S")
  7473. except:
  7474. try:
  7475. effective_dt = datetime.strptime(effective_time, "%Y-%m-%d")
  7476. except:
  7477. pass
  7478. new_version = BizPriceList(
  7479. id=generate_uuid(),
  7480. region_id=region_id,
  7481. supplier_id=supplier_id,
  7482. version_no=version_no,
  7483. status="draft",
  7484. effective_time=effective_dt,
  7485. remark=remark,
  7486. create_at=datetime.now(),
  7487. create_by=current_user.get("user_id"),
  7488. create_name=current_user.get("real_name"),
  7489. is_del=0
  7490. )
  7491. db.add(new_version)
  7492. db.commit()
  7493. return success_response({
  7494. "id": new_version.id,
  7495. "versionNo": version_no,
  7496. "message": "价格版本创建成功"
  7497. })
  7498. @router.post("/price-versions/{version_id}/publish", response_model=dict)
  7499. def publish_price_version(
  7500. version_id: str,
  7501. db: Session = Depends(get_db),
  7502. current_user: dict = Depends(require_admin)
  7503. ):
  7504. """发布价格版本"""
  7505. from app.models.models import BizPriceList
  7506. version = db.query(BizPriceList).filter(BizPriceList.id == version_id, BizPriceList.is_del == 0).first()
  7507. if not version:
  7508. return error_response(404, "价格版本不存在")
  7509. if version.status != "draft":
  7510. return error_response(400, "只有草稿状态的价格版本才能发布")
  7511. version.status = "published"
  7512. version.publish_time = datetime.now()
  7513. version.publisher_id = current_user.get("user_id")
  7514. db.commit()
  7515. # 广播:价格版本发布后,通知该区域客户端刷新价格
  7516. try:
  7517. from app.ws.price_updates import notify_price_list_updated, PriceListUpdatedEvent
  7518. notify_price_list_updated(PriceListUpdatedEvent(
  7519. regionId=str(getattr(version, "region_id", "") or ""),
  7520. priceListId=str(getattr(version, "id", "") or ""),
  7521. priceVersion=int(getattr(version, "version", 0) or 0),
  7522. publishedTime=str(getattr(version, "publish_time", None) or ""),
  7523. ))
  7524. except Exception:
  7525. # 推送失败不影响主流程
  7526. logger.exception(
  7527. "[ws-mini] notify failed in publish_price_version version_id=%s",
  7528. version_id,
  7529. )
  7530. return success_response({
  7531. "id": version.id,
  7532. "status": version.status,
  7533. "message": "价格版本发布成功"
  7534. })
  7535. @router.post("/price-versions/{version_id}/disable", response_model=dict)
  7536. def disable_price_version(
  7537. version_id: str,
  7538. db: Session = Depends(get_db),
  7539. current_user: dict = Depends(require_admin)
  7540. ):
  7541. """禁用价格版本"""
  7542. from app.models.models import BizPriceList
  7543. version = db.query(BizPriceList).filter(BizPriceList.id == version_id, BizPriceList.is_del == 0).first()
  7544. if not version:
  7545. return error_response(404, "价格版本不存在")
  7546. if version.status == "invalid":
  7547. return error_response(400, "该价格版本已经失效")
  7548. version.status = "invalid"
  7549. version.invalidate_time = datetime.now()
  7550. version.invalidate_operator_id = current_user.get("user_id")
  7551. db.commit()
  7552. return success_response({
  7553. "id": version.id,
  7554. "status": version.status,
  7555. "message": "价格版本已禁用"
  7556. })
  7557. @router.get("/price-versions/compare", response_model=dict)
  7558. def compare_price_versions(
  7559. versionIds: str = Query(..., description="版本ID列表,逗号分隔"),
  7560. db: Session = Depends(get_db),
  7561. current_user: dict = Depends(require_admin)
  7562. ):
  7563. """版本价格对比"""
  7564. from app.models.models import BizPriceList, BizPriceListItem
  7565. version_id_list = [v.strip() for v in versionIds.split(",") if v.strip()]
  7566. if len(version_id_list) < 2:
  7567. return error_response(400, "至少需要选择两个版本进行对比")
  7568. versions = db.query(BizPriceList).filter(
  7569. BizPriceList.id.in_(version_id_list),
  7570. BizPriceList.is_del == 0
  7571. ).all()
  7572. if len(versions) < 2:
  7573. return error_response(400, "选择的版本中有不存在的")
  7574. # 获取所有版本的价格明细
  7575. version_items = {}
  7576. all_products = set()
  7577. for version in versions:
  7578. items = db.query(BizPriceListItem).filter(
  7579. BizPriceListItem.price_list_id == version.id,
  7580. BizPriceListItem.is_del == 0
  7581. ).all()
  7582. version_items[version.id] = {item.product_id: float(item.price) if item.price else 0 for item in items}
  7583. all_products.update([item.product_id for item in items])
  7584. # 构建对比数据
  7585. product_map, _cat_map, _unit_map = _prefetch_products_categories_units(
  7586. db, [str(x) for x in all_products]
  7587. )
  7588. compare_data = []
  7589. for product_id in all_products:
  7590. product = product_map.get(str(product_id or ""))
  7591. if not product:
  7592. continue
  7593. row = {
  7594. "productId": product_id,
  7595. "productName": product.name,
  7596. "prices": {}
  7597. }
  7598. for version in versions:
  7599. row["prices"][version.id] = version_items.get(version.id, {}).get(product_id, None)
  7600. if row["prices"][version.id] is not None:
  7601. if not hasattr(row, "lowestPrice") or row["lowestPrice"] > row["prices"][version.id]:
  7602. row["lowestPrice"] = row["prices"][version.id]
  7603. row["lowestVersionId"] = version.id
  7604. compare_data.append(row)
  7605. # 版本基本信息
  7606. version_info = [{
  7607. "id": v.id,
  7608. "versionNo": v.version_no,
  7609. "status": v.status
  7610. } for v in versions]
  7611. return success_response({
  7612. "versions": version_info,
  7613. "products": compare_data
  7614. })