| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577 |
- """运营端接口"""
- import json
- import logging
- import random
- import string
- import uuid
- from typing import Optional, List, Any, Tuple
- from datetime import datetime
- from decimal import Decimal
- from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, Form
- from sqlalchemy.orm import Session
- from sqlalchemy import and_, or_, func
- from sqlalchemy.exc import IntegrityError
- from app.core.database import get_db
- from app.core.auth import get_current_user
- from app.core.security import get_password_hash, verify_password
- from app.core.response import success_response, error_response
- from app.core.storage import storage
- from app.models.models import (
- SysUser, SysRegion, BasProductCategory, BasProductUnit, BasProduct,
- SysUserRegionScope, SysUserRegionScopeHis,
- SysMenu, SysUserMenuScope, SysUserMenuScopeHis,
- BizOrder, BizOrderItem, BizPriceList, BizPriceListItem,
- OrdClientSettlement, OrdClientSettlementItem,
- OrdSupplierSettlement, OrdSupplierSettlementItem,
- )
- from app.api.upload import validate_file, ALLOWED_IMAGE_TYPES, MAX_IMAGE_SIZE
- from app.crud.region import region_crud
- from app.crud.category import category_crud
- from app.crud.unit import unit_crud
- from app.crud.user import user_crud
- from app.crud.password_history import password_history_crud
- from app.crud.product import product_crud
- from app.crud.address import address_crud
- from app.core.utils import generate_id
- from app.core.request_payload import resolve_request_payload
- from app.services.china_region_tree import get_china_region_tree_cached
- router = APIRouter(prefix="/web/admin", tags=["运营端管理接口"])
- logger = logging.getLogger(__name__)
- def _numeric_to_float_optional(v) -> Optional[float]:
- if v is None:
- return None
- return float(v)
- def _category_path_display(db: Session, category_id: Optional[str]) -> str:
- """一级/二级分类用 / 连接(自底向上回溯父级)。"""
- if not category_id:
- return ""
- names: List[str] = []
- cid = str(category_id).strip()
- seen = set()
- depth = 0
- while cid and depth < 24 and cid not in seen:
- seen.add(cid)
- cat = category_crud.get(db, cid)
- if not cat:
- break
- names.insert(0, (getattr(cat, "name", None) or "").strip())
- pid = getattr(cat, "parent_id", None)
- cid = str(pid).strip() if pid else ""
- depth += 1
- return "/".join([n for n in names if n])
- def _supplier_pickup_address_block(db: Session, supplier_id: Optional[str]) -> dict:
- """供应商默认取货地址;无默认时取第一条 pickup 类型地址。"""
- empty = {"fullAddress": None, "contactPerson": None, "contactPhone": None}
- if not supplier_id:
- return empty
- addr = address_crud.get_default(db, supplier_id, "pickup")
- if not addr:
- rows = address_crud.get_by_user_and_type(db, supplier_id, "pickup")
- addr = rows[0] if rows else None
- if not addr:
- return empty
- segs = [
- getattr(addr, "province", None),
- getattr(addr, "city", None),
- getattr(addr, "district", None),
- getattr(addr, "detail_address", None),
- ]
- full = "".join(s for s in segs if s)
- return {
- "fullAddress": full or None,
- "contactPerson": addr.contact_person,
- "contactPhone": addr.contact_phone,
- }
- def require_admin(current_user: dict = Depends(get_current_user)):
- """验证运营人员权限(admin / super_admin)"""
- if current_user.get("role") not in {"admin", "super_admin"}:
- raise HTTPException(status_code=403, detail="需要运营人员权限")
- return current_user
- def get_admin_region_filter(db: Session, current_user: dict) -> Optional[str]:
- """兼容旧逻辑:返回单区域过滤ID。多区域场景返回排序后首个区域。"""
- allowed = get_admin_allowed_region_ids(db, current_user)
- if allowed is None:
- return None
- if not allowed:
- return None
- return sorted(allowed)[0]
- def get_admin_allowed_region_ids(db: Session, current_user: dict) -> Optional[set[str]]:
- """获取管理员允许访问的区域集合。
- - super_admin: 返回 None(表示不限制)
- - admin: 返回可访问区域集合(多区域)
- """
- user_id = current_user.get("user_id")
- if not user_id:
- return set()
- if current_user.get("role") == "super_admin":
- return None
- user = user_crud.get(db, user_id)
- if not user:
- return set()
- scopes = (
- db.query(SysUserRegionScope.region_id)
- .filter(
- SysUserRegionScope.user_id == user_id,
- SysUserRegionScope.is_del == 0,
- )
- .all()
- )
- scope_ids = {str(row[0]).strip() for row in scopes if row and row[0]}
- if scope_ids:
- return scope_ids
- # 向后兼容:若尚未配置多区域表,沿用 sys_user.region_id
- if user.region_id:
- return {str(user.region_id)}
- return set()
- def _effective_settlement_region_id(
- admin_region_id: Optional[str],
- tab_or_filter_region_id: Optional[str],
- ) -> Optional[str]:
- """结算相关接口:区域管理员固定为其绑定区域;超级管理员可按 Tab 的 regionId 筛选。"""
- if admin_region_id:
- return admin_region_id
- return tab_or_filter_region_id or None
- def _aggregate_client_settlement_history(
- db: Session,
- eff_region_id: Optional[str],
- client_id: Optional[str],
- ) -> Tuple[float, int]:
- """已生成客户结算单:结算金额合计、订单笔数合计(与结算单主表 order_count 汇总一致)。"""
- q = db.query(
- func.coalesce(func.sum(OrdClientSettlement.total_amount), 0),
- func.coalesce(func.sum(OrdClientSettlement.order_count), 0),
- ).filter(OrdClientSettlement.is_del == 0)
- if client_id:
- q = q.filter(OrdClientSettlement.client_id == client_id)
- if eff_region_id:
- sub = (
- db.query(OrdClientSettlementItem.settlement_id)
- .join(BizOrder, BizOrder.id == OrdClientSettlementItem.order_id)
- .filter(
- OrdClientSettlementItem.is_del == 0,
- BizOrder.is_del == 0,
- BizOrder.region_id == eff_region_id,
- )
- .distinct()
- )
- q = q.filter(OrdClientSettlement.id.in_(sub))
- row = q.one()
- return float(row[0] or 0), int(row[1] or 0)
- def _aggregate_supplier_settlement_history(
- db: Session,
- eff_region_id: Optional[str],
- supplier_id: Optional[str],
- ) -> Tuple[float, int]:
- """已生成供应商结算单:结算金额合计、订单笔数合计。"""
- q = db.query(
- func.coalesce(func.sum(OrdSupplierSettlement.total_amount), 0),
- func.coalesce(func.sum(OrdSupplierSettlement.order_count), 0),
- ).filter(OrdSupplierSettlement.is_del == 0)
- if supplier_id:
- q = q.filter(OrdSupplierSettlement.supplier_id == supplier_id)
- if eff_region_id:
- sub = (
- db.query(OrdSupplierSettlementItem.settlement_id)
- .join(BizOrder, BizOrder.id == OrdSupplierSettlementItem.order_id)
- .filter(
- OrdSupplierSettlementItem.is_del == 0,
- BizOrder.is_del == 0,
- BizOrder.region_id == eff_region_id,
- )
- .distinct()
- )
- q = q.filter(OrdSupplierSettlement.id.in_(sub))
- row = q.one()
- return float(row[0] or 0), int(row[1] or 0)
- _REGION_MINUTES_UNSET = object()
- def _minutes_value_from_request(request: dict, camel: str, snake: str):
- """解析区域配送相关分钟配置;键不存在返回 _REGION_MINUTES_UNSET,空值为 None。"""
- if camel not in request and snake not in request:
- return _REGION_MINUTES_UNSET
- raw = request.get(camel, request.get(snake))
- if raw is None or raw == "":
- return None
- try:
- n = int(raw)
- except (TypeError, ValueError):
- raise ValueError("预计配送时长、预警提前时间须为非负整数(分钟)")
- if n < 0:
- raise ValueError("预计配送时长、预警提前时间须为非负整数(分钟)")
- return n
- def _apply_region_minutes_to_data(request: dict, data: dict):
- """将 expectedDeliveryMinutes / deliveryWarnAdvanceMinutes 写入 data(字典为 create_data 或 update_data)。失败返回 error_response 的 dict。"""
- try:
- ed = _minutes_value_from_request(
- request, "expectedDeliveryMinutes", "expected_delivery_minutes"
- )
- wa = _minutes_value_from_request(
- request, "deliveryWarnAdvanceMinutes", "delivery_warn_advance_minutes"
- )
- except ValueError as e:
- return error_response(400, str(e))
- if ed is not _REGION_MINUTES_UNSET:
- data["expected_delivery_minutes"] = ed
- if wa is not _REGION_MINUTES_UNSET:
- data["delivery_warn_advance_minutes"] = wa
- return None
- def _resolve_admin_payload_with_crypto_fallback(request: dict, current_user: dict):
- """兼容明文/密文请求,返回业务 payload。失败时返回 error_response。"""
- payload = resolve_request_payload(request, current_user, source="admin")
- if not payload:
- req_dict = request if isinstance(request, dict) else {}
- if req_dict.get("enc") == 1:
- return {}, error_response(400, "密文解密失败")
- return payload if isinstance(payload, dict) else {}, None
- # ============ 中国行政区划(省市区,bas_china_region)============
- @router.get("/china-region-tree", response_model=dict)
- def get_china_region_tree_admin(
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """省市区三级树,供运营端级联选择器等使用(数据源 bas_china_region)。"""
- return success_response({"tree": get_china_region_tree_cached(db)})
- # ============ 区域管理 ============
- @router.get("/regions", response_model=dict)
- def list_regions(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- keyword: Optional[str] = Query(None, description="关键词搜索"),
- isActive: Optional[bool] = Query(None, description="是否启用"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取区域列表"""
- query = db.query(SysRegion).filter(SysRegion.is_del == 0)
-
- if keyword:
- query = query.filter(
- (SysRegion.name.like(f"%{keyword}%")) |
- (SysRegion.code.like(f"%{keyword}%"))
- )
- if isActive is not None:
- query = query.filter(SysRegion.is_active == isActive)
-
- total = query.count()
- skip = (page - 1) * pageSize
- regions = query.order_by(SysRegion.sort_order.asc()).offset(skip).limit(pageSize).all()
-
- return success_response({
- "list": [{"id": r.id, "name": r.name, "code": r.code, "description": r.description,
- "sortOrder": r.sort_order,
- "expectedDeliveryMinutes": r.expected_delivery_minutes,
- "deliveryWarnAdvanceMinutes": r.delivery_warn_advance_minutes,
- "isActive": r.is_active,
- "createAt": str(r.create_at) if r.create_at else None} for r in regions],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/regions/active", response_model=dict)
- def list_active_regions(
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取所有启用区域(用于下拉选择)"""
- regions = region_crud.get_active_all(db)
- return success_response([{"id": r.id, "name": r.name, "code": r.code} for r in regions])
- @router.get("/regions/{region_id}", response_model=dict)
- def get_region(
- region_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取区域详情"""
- region = region_crud.get(db, region_id)
- if not region:
- return error_response(404, "区域不存在")
- return success_response({
- "id": region.id,
- "name": region.name,
- "code": region.code,
- "description": region.description,
- "sortOrder": region.sort_order,
- "expectedDeliveryMinutes": region.expected_delivery_minutes,
- "deliveryWarnAdvanceMinutes": region.delivery_warn_advance_minutes,
- "isActive": region.is_active,
- "createAt": str(region.create_at) if region.create_at else None,
- "createBy": region.create_by,
- "createName": region.create_name
- })
- @router.post("/regions", response_model=dict)
- def create_region(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """创建区域"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- # 检查编码唯一性
- existing = region_crud.get_by_code(db, request.get("code"))
- if existing:
- return error_response(400, "区域编码已存在")
- # 字段名映射
- create_data = {
- "name": request.get("name"),
- "code": request.get("code"),
- "description": request.get("description"),
- }
- if "sortOrder" in request or "sort_order" in request:
- create_data["sort_order"] = request.get("sortOrder", request.get("sort_order", 0))
- if "isActive" in request or "is_active" in request:
- create_data["is_active"] = request.get("isActive", request.get("is_active", True))
- err = _apply_region_minutes_to_data(request, create_data)
- if err is not None:
- return err
- creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- region = region_crud.create(db, create_data, creator)
- return success_response({
- "id": region.id,
- "name": region.name,
- "code": region.code,
- "description": region.description,
- "sortOrder": region.sort_order,
- "expectedDeliveryMinutes": region.expected_delivery_minutes,
- "deliveryWarnAdvanceMinutes": region.delivery_warn_advance_minutes,
- "isActive": region.is_active,
- "createAt": str(region.create_at) if region.create_at else None,
- "createBy": region.create_by,
- "createName": region.create_name
- })
- @router.put("/regions/{region_id}", response_model=dict)
- def update_region(
- region_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """更新区域"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- # 检查编码唯一性(排除自己)
- if "code" in request and request["code"]:
- existing = region_crud.get_by_code(db, request["code"])
- if existing and existing.id != region_id:
- return error_response(400, "区域编码已存在")
- # 字段名映射:仅更新请求中显式传入的字段,避免未传字段被写成 None
- update_data: dict = {}
- if "name" in request:
- update_data["name"] = request.get("name")
- if "code" in request:
- update_data["code"] = request.get("code")
- if "description" in request:
- update_data["description"] = request.get("description")
- if "sortOrder" in request or "sort_order" in request:
- update_data["sort_order"] = request.get("sortOrder", request.get("sort_order", 0))
- if "isActive" in request or "is_active" in request:
- update_data["is_active"] = request.get("isActive", request.get("is_active", True))
- err = _apply_region_minutes_to_data(request, update_data)
- if err is not None:
- return err
- if not update_data:
- return error_response(400, "未检测到可更新字段")
- updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- region = region_crud.update_with_history(db, region_id, update_data, updater)
- if not region:
- return error_response(404, "区域不存在")
- return success_response({
- "id": region.id,
- "name": region.name,
- "code": region.code,
- "description": region.description,
- "sortOrder": region.sort_order,
- "expectedDeliveryMinutes": region.expected_delivery_minutes,
- "deliveryWarnAdvanceMinutes": region.delivery_warn_advance_minutes,
- "isActive": region.is_active
- })
- @router.delete("/regions/{region_id}", response_model=dict)
- def delete_region(
- region_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """删除区域"""
- success = region_crud.soft_delete_with_history(db, region_id)
- if not success:
- return error_response(404, "区域不存在")
- return success_response({"id": region_id})
- # ============ 商品分类管理 ============
- def _category_price_coefficient_value(category: BasProductCategory) -> float:
- v = getattr(category, "price_coefficient", None)
- if v is None:
- return 0.0
- return float(v)
- @router.get("/categories", response_model=dict)
- def list_categories(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- keyword: Optional[str] = Query(None, description="关键词搜索"),
- isActive: Optional[bool] = Query(None, description="是否启用"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取商品分类列表"""
- query = db.query(BasProductCategory).filter(BasProductCategory.is_del == 0)
-
- if keyword:
- query = query.filter(BasProductCategory.name.like(f"%{keyword}%"))
- if isActive is not None:
- query = query.filter(BasProductCategory.is_active == isActive)
-
- total = query.count()
- skip = (page - 1) * pageSize
- categories = query.order_by(BasProductCategory.sort_order.asc()).offset(skip).limit(pageSize).all()
-
- # 查询所有分类用于获取父分类名称
- all_categories = db.query(BasProductCategory).filter(BasProductCategory.is_del == 0).all()
- parent_map = {c.id: c.name for c in all_categories}
-
- return success_response({
- "list": [{
- "id": c.id,
- "name": c.name,
- "parentId": c.parent_id,
- "parentName": parent_map.get(c.parent_id) if c.parent_id else None,
- "sortOrder": c.sort_order,
- "priceCoefficient": _category_price_coefficient_value(c),
- "isActive": c.is_active,
- "imageUrl": getattr(c, "image_url", None),
- } for c in categories],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/categories/tree", response_model=dict)
- def get_category_tree(
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取商品分类树形结构"""
- tree = category_crud.get_tree(db)
- return success_response(tree)
- @router.get("/categories/active", response_model=dict)
- def list_active_categories(
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取所有启用分类"""
- categories = category_crud.get_active_all(db)
- return success_response([{
- "id": c.id,
- "name": c.name,
- "parentId": c.parent_id,
- "priceCoefficient": _category_price_coefficient_value(c),
- } for c in categories])
- @router.get("/categories/{category_id}", response_model=dict)
- def get_category(
- category_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取分类详情"""
- category = category_crud.get(db, category_id)
- if not category:
- return error_response(404, "分类不存在")
- return success_response({
- "id": category.id,
- "name": category.name,
- "parentId": category.parent_id,
- "sortOrder": category.sort_order,
- "priceCoefficient": _category_price_coefficient_value(category),
- "isActive": category.is_active,
- "imageUrl": getattr(category, "image_url", None),
- })
- @router.post("/categories", response_model=dict)
- def create_category(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """创建商品分类"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- create_data = {
- "name": request.get("name"),
- }
- # 前端传参使用驼峰命名:parentId/sortOrder/isActive/imageUrl
- if "parentId" in request or "parent_id" in request:
- v = request.get("parentId", request.get("parent_id"))
- create_data["parent_id"] = v or None
- if "sortOrder" in request or "sort_order" in request:
- create_data["sort_order"] = request.get("sortOrder", request.get("sort_order"))
- if "isActive" in request or "is_active" in request:
- create_data["is_active"] = request.get("isActive", request.get("is_active"))
- if "imageUrl" in request or "image_url" in request:
- v = request.get("imageUrl", request.get("image_url"))
- create_data["image_url"] = v or None
- if "priceCoefficient" in request or "price_coefficient" in request:
- raw = request.get("priceCoefficient", request.get("price_coefficient"))
- if raw is not None and raw != "":
- try:
- create_data["price_coefficient"] = float(raw)
- except (TypeError, ValueError):
- return error_response(400, "价格系数格式不正确")
- category = category_crud.create(db, create_data, creator)
-
- return success_response({
- "id": category.id,
- "name": category.name,
- "parentId": category.parent_id,
- "sortOrder": category.sort_order,
- "priceCoefficient": _category_price_coefficient_value(category),
- "isActive": category.is_active,
- "imageUrl": getattr(category, "image_url", None),
- })
- @router.put("/categories/{category_id}", response_model=dict)
- def update_category(
- category_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """更新商品分类"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- update_data: dict = {}
- if "name" in request:
- update_data["name"] = request.get("name")
- if "parentId" in request or "parent_id" in request:
- v = request.get("parentId", request.get("parent_id"))
- update_data["parent_id"] = v or None
- if "sortOrder" in request or "sort_order" in request:
- update_data["sort_order"] = request.get("sortOrder", request.get("sort_order"))
- if "isActive" in request or "is_active" in request:
- update_data["is_active"] = request.get("isActive", request.get("is_active"))
- if "imageUrl" in request or "image_url" in request:
- v = request.get("imageUrl", request.get("image_url"))
- update_data["image_url"] = v or None
- if "priceCoefficient" in request or "price_coefficient" in request:
- raw = request.get("priceCoefficient", request.get("price_coefficient"))
- if raw is None or raw == "":
- update_data["price_coefficient"] = 0.0
- else:
- try:
- update_data["price_coefficient"] = float(raw)
- except (TypeError, ValueError):
- return error_response(400, "价格系数格式不正确")
- category = category_crud.update_with_history(db, category_id, update_data, updater)
-
- if not category:
- return error_response(404, "分类不存在")
-
- return success_response({
- "id": category.id,
- "name": category.name,
- "parentId": category.parent_id,
- "sortOrder": category.sort_order,
- "priceCoefficient": _category_price_coefficient_value(category),
- "isActive": category.is_active,
- "imageUrl": getattr(category, "image_url", None),
- })
- @router.delete("/categories/{category_id}", response_model=dict)
- def delete_category(
- category_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """删除商品分类"""
- success = category_crud.soft_delete_with_history(db, category_id)
- if not success:
- return error_response(404, "分类不存在")
- return success_response({"id": category_id})
- @router.post("/categories/upload-image", response_model=dict)
- async def upload_category_image(
- file: UploadFile = File(...),
- folder: str = Form("categories"),
- current_user: dict = Depends(require_admin),
- ):
- """上传商品分类图片(MinIO)并返回图片URL"""
- try:
- content = await validate_file(file, MAX_IMAGE_SIZE, ALLOWED_IMAGE_TYPES)
- original_filename = file.filename or "category.png"
- url = storage.upload_file(content, original_filename, folder)
- return success_response({"url": url})
- except HTTPException:
- raise
- except Exception as e:
- return error_response(500, f"上传失败: {str(e)}")
- # ============ 商品单位管理 ============
- @router.get("/units", response_model=dict)
- def list_units(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- keyword: Optional[str] = Query(None, description="关键词搜索"),
- isActive: Optional[bool] = Query(None, description="是否启用"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取商品单位列表"""
- query = db.query(BasProductUnit).filter(BasProductUnit.is_del == 0)
-
- if keyword:
- query = query.filter(
- (BasProductUnit.name.like(f"%{keyword}%")) |
- (BasProductUnit.code.like(f"%{keyword}%"))
- )
- if isActive is not None:
- query = query.filter(BasProductUnit.is_active == isActive)
-
- total = query.count()
- skip = (page - 1) * pageSize
- units = query.order_by(BasProductUnit.sort_order.asc(), BasProductUnit.create_at.desc()).offset(skip).limit(pageSize).all()
-
- return success_response({
- "list": [{"id": u.id, "name": u.name, "code": u.code, "isActive": u.is_active, "sortOrder": u.sort_order} for u in units],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/units/active", response_model=dict)
- def list_active_units(
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取所有启用单位"""
- units = unit_crud.get_active_all(db)
- return success_response([{"id": u.id, "name": u.name, "code": u.code} for u in units])
- @router.get("/units/{unit_id}", response_model=dict)
- def get_unit(
- unit_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取单位详情"""
- unit = unit_crud.get(db, unit_id)
- if not unit:
- return error_response(404, "单位不存在")
- return success_response({
- "id": unit.id,
- "name": unit.name,
- "code": unit.code,
- "isActive": unit.is_active,
- "sortOrder": unit.sort_order
- })
- @router.post("/units", response_model=dict)
- def create_unit(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """创建商品单位"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- # 检查编码唯一性
- existing = unit_crud.get_by_code(db, request.get("code"))
- if existing:
- return error_response(400, "单位编码已存在")
-
- creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- unit = unit_crud.create(db, request, creator)
-
- return success_response({
- "id": unit.id,
- "name": unit.name,
- "code": unit.code,
- "isActive": unit.is_active,
- "sortOrder": unit.sort_order
- })
- @router.put("/units/{unit_id}", response_model=dict)
- def update_unit(
- unit_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """更新商品单位"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- # 检查编码唯一性
- if "code" in request and request["code"]:
- existing = unit_crud.get_by_code(db, request["code"])
- if existing and existing.id != unit_id:
- return error_response(400, "单位编码已存在")
-
- updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- unit = unit_crud.update_with_history(db, unit_id, request, updater)
-
- if not unit:
- return error_response(404, "单位不存在")
-
- return success_response({
- "id": unit.id,
- "name": unit.name,
- "code": unit.code,
- "isActive": unit.is_active,
- "sortOrder": unit.sort_order
- })
- @router.delete("/units/{unit_id}", response_model=dict)
- def delete_unit(
- unit_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """删除商品单位"""
- success = unit_crud.soft_delete_with_history(db, unit_id)
- if not success:
- return error_response(404, "单位不存在")
- return success_response({"id": unit_id})
- # ============ 用户管理 ============
- # 角色定义常量
- ROLE_OPTIONS = [
- {"label": "超级管理员", "value": "super_admin"},
- {"label": "运营管理员", "value": "admin"},
- {"label": "供应商", "value": "supplier"},
- {"label": "客户", "value": "client"},
- {"label": "配送员", "value": "deliver"}
- ]
- def _get_user_region_scope_ids(db: Session, user_id: str) -> list[str]:
- rows = (
- db.query(SysUserRegionScope.region_id)
- .filter(
- SysUserRegionScope.user_id == user_id,
- SysUserRegionScope.is_del == 0,
- )
- .all()
- )
- ids = [str(row[0]).strip() for row in rows if row and row[0]]
- uniq = sorted({x for x in ids if x})
- return uniq
- def _batch_scope_region_ids_by_user(db: Session, user_ids: list[str]) -> dict[str, set[str]]:
- """批量读取用户区域授权 scope 中的 region_id(未删除),按 user_id 分组。"""
- if not user_ids:
- return {}
- rows = (
- db.query(SysUserRegionScope.user_id, SysUserRegionScope.region_id)
- .filter(
- SysUserRegionScope.user_id.in_(user_ids),
- SysUserRegionScope.is_del == 0,
- )
- .all()
- )
- out: dict[str, set[str]] = {}
- for uid, rid in rows:
- if not uid or not rid:
- continue
- us = str(uid).strip()
- rs = str(rid).strip()
- if not us or not rs:
- continue
- out.setdefault(us, set()).add(rs)
- return out
- def _user_display_region_ids(user: SysUser, scopes_by_user: dict[str, set[str]]) -> list[str]:
- """列表/详情展示用:授权 scope 中的区域 + 用户主 region_id 并集,去重排序。"""
- uid = str(user.id).strip()
- ids = set(scopes_by_user.get(uid, set()))
- if user.region_id:
- rid = str(user.region_id).strip()
- if rid:
- ids.add(rid)
- ids.discard("")
- return sorted(ids)
- def _join_region_names(region_ids: list[str], id_to_name: dict[str, str]) -> Optional[str]:
- names = [id_to_name[i] for i in region_ids if id_to_name.get(i)]
- return ", ".join(names) if names else None
- def _region_names_csv(db: Session, region_ids: list[str]) -> Optional[str]:
- if not region_ids:
- return None
- regs = (
- db.query(SysRegion.id, SysRegion.name)
- .filter(SysRegion.id.in_(region_ids), SysRegion.is_del == 0)
- .all()
- )
- m = {r.id: r.name for r in regs}
- return _join_region_names(region_ids, m)
- def _replace_user_region_scopes(
- db: Session,
- user_id: str,
- region_ids: list[str],
- operator_id: Optional[str],
- operator_name: Optional[str],
- ):
- current = (
- db.query(SysUserRegionScope)
- .filter(
- SysUserRegionScope.user_id == user_id,
- SysUserRegionScope.is_del == 0,
- )
- .all()
- )
- for scope in current:
- db.add(
- SysUserRegionScopeHis(
- id=scope.id,
- user_id=scope.user_id,
- region_id=scope.region_id,
- create_at=scope.create_at,
- create_by=scope.create_by,
- create_name=scope.create_name,
- update_at=scope.update_at,
- update_by=scope.update_by,
- )
- )
- scope.is_del = 1
- scope.update_by = operator_id
- for rid in sorted({str(x).strip() for x in (region_ids or []) if str(x).strip()}):
- db.add(
- SysUserRegionScope(
- id=generate_id(),
- user_id=user_id,
- region_id=rid,
- is_del=0,
- create_by=operator_id,
- create_name=operator_name,
- update_by=operator_id,
- )
- )
- @router.get("/roles", response_model=dict)
- def list_roles(
- current_user: dict = Depends(require_admin)
- ):
- """获取角色列表"""
- return success_response(ROLE_OPTIONS)
- @router.get("/menus/assignable-codes", response_model=dict)
- def list_assignable_menu_codes(
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """可授权菜单 code 列表(仅 menu 类型)。"""
- menus = (
- db.query(SysMenu)
- .filter(
- SysMenu.is_del == 0,
- SysMenu.is_active == 1,
- SysMenu.menu_type == "menu",
- SysMenu.code.isnot(None),
- )
- .order_by(SysMenu.sort_order.asc())
- .all()
- )
- return success_response({
- "list": [
- {
- "id": m.id,
- "code": m.code,
- "name": m.name,
- "parentId": m.parent_id,
- "sortOrder": m.sort_order,
- }
- for m in menus
- ]
- })
- @router.get("/users/{user_id}/menu-scopes", response_model=dict)
- def get_user_menu_scopes(
- user_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """查询用户菜单授权 code 列表。"""
- user = user_crud.get(db, user_id)
- if not user:
- return error_response(404, "用户不存在")
- rows = (
- db.query(SysUserMenuScope.menu_code)
- .filter(
- SysUserMenuScope.user_id == user_id,
- SysUserMenuScope.is_del == 0,
- )
- .all()
- )
- codes = sorted({str(row[0]).strip() for row in rows if row and row[0]})
- return success_response({
- "userId": user_id,
- "menuCodes": codes
- })
- @router.put("/users/{user_id}/menu-scopes", response_model=dict)
- def put_user_menu_scopes(
- user_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """更新用户菜单授权(按 code 全量覆盖;兼容明文/密文载荷)。"""
- return _update_user_menu_scopes(user_id, request, db, current_user)
- @router.post("/users/{user_id}/menu-scopes", response_model=dict)
- def post_user_menu_scopes(
- user_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """与 PUT 同逻辑:前端按 POST 模式调用时复用。"""
- return _update_user_menu_scopes(user_id, request, db, current_user)
- def _update_user_menu_scopes(user_id: str, request: dict, db: Session, current_user: dict):
- """更新用户菜单授权(按 code 全量覆盖;兼容明文/密文载荷)。"""
- from app.core import api_crypto
- from app.core.config import settings
- user = user_crud.get(db, user_id)
- if not user:
- return error_response(404, "用户不存在")
- if user.role == "super_admin":
- return error_response(400, "超级管理员无需配置菜单权限")
- req_keys: list[str] = []
- try:
- req_keys = list((request or {}).keys())
- except Exception:
- req_keys = []
- req_dict = request or {}
- # 明文载荷:{ menuCodes: [...] }
- raw_codes = req_dict.get("menuCodes", [])
- # 密文信封:{ enc, ts, nonce, edata }
- # 若信封里没有 menuCodes,则尝试解密回退。
- if (
- isinstance(req_dict, dict)
- and req_dict.get("enc") == 1
- and "menuCodes" not in req_dict
- and req_dict.get("edata")
- ):
- try:
- ts = req_dict.get("ts")
- nonce = req_dict.get("nonce")
- edata = req_dict.get("edata")
- fixed_secret = getattr(settings, "API_ENCRYPT_FIXED_SECRET", "") or ""
- operator_user_id = current_user.get("user_id") or ""
- if not (fixed_secret and operator_user_id and ts is not None and nonce):
- return error_response(400, "密文解析配置缺失")
- key = api_crypto.derive_key(
- fixed_secret=fixed_secret,
- user_id=str(operator_user_id),
- ts=int(ts),
- nonce=str(nonce),
- )
- plain = api_crypto.decrypt_json(str(edata), key)
- payload = plain.get("data", plain) if isinstance(plain, dict) else {}
- raw_codes = payload.get("menuCodes", []) if isinstance(payload, dict) else []
- except Exception as exc:
- return error_response(400, f"密文解密失败: {exc}")
- if raw_codes is None:
- raw_codes = []
- if not isinstance(raw_codes, list):
- return error_response(400, "menuCodes 必须为数组")
- menu_codes = sorted({str(code).strip() for code in raw_codes if str(code).strip()})
- desired_codes_set = {str(code).strip() for code in menu_codes if str(code).strip()}
- # 调试:定位前端提交是否真的到后端(避免把全量内容写日志,保留前几个)
- try:
- preview = menu_codes[:10]
- except Exception:
- preview = []
- print(
- f"[menu-scope-save] user_id={user_id} req_keys={req_keys} "
- f"menu_codes_len={len(menu_codes)} preview={preview}"
- )
- operator_id = current_user.get("user_id")
- operator_name = current_user.get("real_name")
- # sys_user_menu_scope 唯一约束:uk_user_menu_scope (user_id, menu_code, is_del)
- # 这意味着同一 (user_id, menu_code) 可能同时存在 is_del=0 与 is_del=1 两条记录。
- # 当我们需要把某条从 1 更新为 0(或相反)时,可能会撞唯一键,因此需要先“归一化”:
- # - 目标授权: 最终只保留 is_del=0
- # - 非目标: 最终只保留 is_del=1(允许该 code 不存在)
- current_scopes = db.query(SysUserMenuScope).filter(SysUserMenuScope.user_id == user_id).all()
- scopes_by_code_and_del: dict[str, dict[int, SysUserMenuScope]] = {}
- for scope in current_scopes:
- code = str(scope.menu_code).strip()
- del_key = int(scope.is_del or 0)
- scopes_by_code_and_del.setdefault(code, {})[del_key] = scope
- def _add_scope_his(scope: SysUserMenuScope):
- db.add(
- SysUserMenuScopeHis(
- id=scope.id,
- user_id=scope.user_id,
- menu_code=scope.menu_code,
- create_at=scope.create_at,
- create_by=scope.create_by,
- create_name=scope.create_name,
- update_at=scope.update_at,
- update_by=scope.update_by,
- )
- )
- all_codes = set(scopes_by_code_and_del.keys()) | desired_codes_set
- for code in all_codes:
- target_is_del = 0 if code in desired_codes_set else 1
- bucket = scopes_by_code_and_del.get(code) or {}
- scope0 = bucket.get(0)
- scope1 = bucket.get(1)
- if target_is_del == 0:
- # 需要“未删除态”
- if scope0 and scope1:
- # 两种状态都存在:保留 is_del=0,删除 is_del=1 那条(否则无法通过 update 归一化)
- _add_scope_his(scope1)
- db.delete(scope1)
- elif scope0:
- # 仅 is_del=0 存在:只更新审计字段
- scope0.update_by = operator_id
- elif scope1:
- # 仅 is_del=1 存在:恢复为 is_del=0
- _add_scope_his(scope1)
- scope1.is_del = 0
- scope1.update_by = operator_id
- else:
- # 两条都不存在:新增 is_del=0
- db.add(
- SysUserMenuScope(
- id=generate_id(),
- user_id=user_id,
- menu_code=code,
- is_del=0,
- create_by=operator_id,
- create_name=operator_name,
- update_by=operator_id,
- )
- )
- else:
- # 需要“删除态” (is_del=1)
- if scope0 and scope1:
- # 两种状态都存在:保留 is_del=1,删除 is_del=0 那条
- _add_scope_his(scope0)
- db.delete(scope0)
- elif scope1:
- # 仅 is_del=1 存在:只更新审计字段
- scope1.update_by = operator_id
- elif scope0:
- # 仅 is_del=0 存在:置为 is_del=1
- _add_scope_his(scope0)
- scope0.is_del = 1
- scope0.update_by = operator_id
- else:
- # 目标是删除态但表中不存在:无需新增一条 is_del=1 记录
- pass
- db.commit()
- return success_response({"userId": user_id, "menuCodes": menu_codes})
- @router.get("/users", response_model=dict)
- def list_users(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- role: Optional[str] = Query(None, description="角色筛选"),
- roleIn: Optional[str] = Query(None, description="角色集合筛选,逗号分隔"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- keyword: Optional[str] = Query(None, description="关键词搜索"),
- isActive: Optional[bool] = Query(None, description="是否激活"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取用户列表"""
- role_in_values: Optional[list[str]] = None
- if roleIn:
- role_in_values = [x.strip() for x in str(roleIn).split(",") if x and x.strip()]
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None:
- if regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- if not regionId and allowed_region_ids:
- regionId = sorted(allowed_region_ids)[0]
- users, total = user_crud.get_multi_with_filters(
- db,
- role=role,
- roles=role_in_values,
- region_id=regionId,
- keyword=keyword,
- is_active=isActive,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- uid_list = [u.id for u in users]
- scopes_by_user = _batch_scope_region_ids_by_user(db, uid_list)
- all_region_ids: set[str] = set()
- for u in users:
- all_region_ids.update(_user_display_region_ids(u, scopes_by_user))
- id_to_name: dict[str, str] = {}
- if all_region_ids:
- regs = (
- db.query(SysRegion.id, SysRegion.name)
- .filter(SysRegion.id.in_(list(all_region_ids)), SysRegion.is_del == 0)
- .all()
- )
- id_to_name = {r.id: r.name for r in regs}
- return success_response({
- "list": [{
- "id": u.id,
- "username": u.username,
- "realName": u.real_name,
- "phone": u.phone,
- "avatarUrl": (str(u.avatar_url).strip() if getattr(u, "avatar_url", None) else None) or None,
- "role": u.role,
- "regionId": u.region_id,
- "regionName": _join_region_names(_user_display_region_ids(u, scopes_by_user), id_to_name),
- "regionIds": sorted(scopes_by_user.get(str(u.id).strip(), set())),
- "isActive": u.is_active,
- "createAt": str(u.create_at) if u.create_at else None,
- "createName": getattr(u, "create_name", None)
- } for u in users],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/users/{user_id}", response_model=dict)
- def get_user(
- user_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取用户详情"""
- user = user_crud.get(db, user_id)
- if not user:
- return error_response(404, "用户不存在")
-
- scopes_by_user = _batch_scope_region_ids_by_user(db, [user.id])
- region_name = _region_names_csv(db, _user_display_region_ids(user, scopes_by_user))
- return success_response({
- "id": user.id,
- "username": user.username,
- "realName": user.real_name,
- "phone": user.phone,
- "avatarUrl": (str(user.avatar_url).strip() if getattr(user, "avatar_url", None) else None) or None,
- "role": user.role,
- "regionId": user.region_id,
- "regionName": region_name,
- "regionIds": _get_user_region_scope_ids(db, user.id),
- "isActive": user.is_active,
- "createAt": str(user.create_at) if user.create_at else None,
- "createName": getattr(user, "create_name", None)
- })
- @router.post("/users", response_model=dict)
- def create_user(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """创建用户"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- # 检查用户名唯一性
- existing = user_crud.get_by_username(db, request.get("username"))
- if existing:
- return error_response(400, "用户名已存在")
-
- creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- body = dict(request) if request else {}
- region_ids = body.pop("regionIds", None)
- if body.get("avatarUrl") == "":
- body["avatarUrl"] = None
- try:
- user, initial_password = user_crud.create_with_password(db, body, creator)
- except ValueError as e:
- return error_response(400, str(e))
- if isinstance(region_ids, list):
- _replace_user_region_scopes(db, user.id, region_ids, current_user.get("user_id"), current_user.get("real_name"))
- db.commit()
- payload = {
- "id": user.id,
- "username": user.username,
- "realName": user.real_name,
- "avatarUrl": (str(user.avatar_url).strip() if getattr(user, "avatar_url", None) else None) or None,
- "regionIds": _get_user_region_scope_ids(db, user.id),
- }
- if initial_password is not None:
- payload["initialPassword"] = initial_password
- return success_response(payload)
- @router.put("/users/{user_id}", response_model=dict)
- def update_user(
- user_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """更新用户"""
- updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- req = dict(request) if request else {}
- region_ids = req.pop("regionIds", None)
- if req.get("avatarUrl") == "":
- req["avatarUrl"] = None
- user = user_crud.update_with_history(db, user_id, req, updater)
-
- if not user:
- return error_response(404, "用户不存在")
-
- if isinstance(region_ids, list):
- _replace_user_region_scopes(db, user_id, region_ids, current_user.get("user_id"), current_user.get("real_name"))
- db.commit()
- scopes_by_user = _batch_scope_region_ids_by_user(db, [user.id])
- region_name = _region_names_csv(db, _user_display_region_ids(user, scopes_by_user))
- return success_response({
- "id": user.id,
- "username": user.username,
- "realName": user.real_name,
- "phone": user.phone,
- "avatarUrl": (str(user.avatar_url).strip() if getattr(user, "avatar_url", None) else None) or None,
- "role": user.role,
- "regionId": user.region_id,
- "regionName": region_name,
- "regionIds": _get_user_region_scope_ids(db, user.id),
- "isActive": user.is_active
- })
- @router.delete("/users/{user_id}", response_model=dict)
- def delete_user(
- user_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """删除用户"""
- # 不能删除自己
- if user_id == current_user.get("user_id"):
- return error_response(400, "不能删除当前登录账号")
-
- success = user_crud.soft_delete_with_history(db, user_id)
- if not success:
- return error_response(404, "用户不存在")
- return success_response({"id": user_id})
- @router.post("/users/upload-avatar", response_model=dict)
- async def upload_user_avatar(
- file: UploadFile = File(...),
- folder: str = Form("avatars"),
- current_user: dict = Depends(require_admin),
- ):
- """上传用户头像图片,返回可写入 avatarUrl 的地址"""
- try:
- content = await validate_file(file, MAX_IMAGE_SIZE, ALLOWED_IMAGE_TYPES)
- original_filename = file.filename or "avatar.png"
- url = storage.upload_file(content, original_filename, folder)
- return success_response({"url": url})
- except HTTPException:
- raise
- except Exception as e:
- return error_response(500, f"上传失败: {str(e)}")
- @router.post("/users/{user_id}/reset-password", response_model=dict)
- def reset_user_password(
- user_id: str,
- request: Request,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """重置用户密码为服务端固定默认(明文不由请求传入,响应中也不返回密码)"""
- try:
- success = user_crud.reset_password_to_admin_default(db, user_id)
- except ValueError as e:
- return error_response(400, str(e))
- if not success:
- return error_response(404, "用户不存在")
- ip_address = get_client_ip(request)
- password_history_crud.create_password_history(
- db,
- user_id=user_id,
- operation_type="reset",
- operator_id=current_user.get("user_id"),
- operator_name=current_user.get("real_name"),
- ip_address=ip_address
- )
- return success_response({"ok": True})
- @router.get("/users/{user_id}/password-history", response_model=dict)
- def get_user_password_history(
- user_id: str,
- page: int = Query(1, ge=1, description="页码"),
- page_size: int = Query(10, ge=1, le=50, description="每页条数"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取用户密码修改历史记录"""
- try:
- from app.models.models import SysPasswordHistory
- query = db.query(SysPasswordHistory).filter(
- SysPasswordHistory.user_id == user_id,
- SysPasswordHistory.is_del == 0
- )
- total = query.count()
- items = query.order_by(SysPasswordHistory.create_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
- result = []
- for item in items:
- result.append({
- "id": item.id,
- "userId": item.user_id,
- "operationType": item.operation_type,
- "operationTypeText": "重置密码" if item.operation_type == "reset" else "修改密码" if item.operation_type == "change" else item.operation_type,
- "operatorName": item.operator_name,
- "operatorId": item.operator_id,
- "ipAddress": item.ip_address,
- "createAt": str(item.create_at) if item.create_at else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": page_size
- })
- except Exception as e:
- return error_response(500, f"获取密码历史失败: {str(e)}")
- @router.get("/password-history", response_model=dict)
- def get_password_history_list(
- page: int = Query(1, ge=1, description="页码"),
- page_size: int = Query(10, ge=1, le=50, description="每页条数"),
- user_id: str = Query(None, description="用户ID"),
- operation_type: str = Query(None, description="操作类型"),
- keyword: str = Query(None, description="关键词搜索"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取密码修改历史记录列表(后台管理用)"""
- try:
- from app.models.models import SysPasswordHistory
- query = db.query(SysPasswordHistory).filter(SysPasswordHistory.is_del == 0)
- if user_id:
- query = query.filter(SysPasswordHistory.user_id == user_id)
- if operation_type:
- query = query.filter(SysPasswordHistory.operation_type == operation_type)
- if keyword:
- query = query.filter(SysPasswordHistory.operator_name.like(f"%{keyword}%"))
- total = query.count()
- items = query.order_by(SysPasswordHistory.create_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
- result = []
- for item in items:
- # 获取用户信息
- user_info = {}
- user = user_crud.get(db, item.user_id)
- if user:
- user_info = {
- "userName": user.username,
- "realName": user.real_name,
- "role": user.role
- }
- result.append({
- "id": item.id,
- "userId": item.user_id,
- "userInfo": user_info,
- "operationType": item.operation_type,
- "operationTypeText": "重置密码" if item.operation_type == "reset" else "修改密码" if item.operation_type == "change" else item.operation_type,
- "operatorName": item.operator_name,
- "operatorId": item.operator_id,
- "ipAddress": item.ip_address,
- "createAt": str(item.create_at) if item.create_at else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": page_size
- })
- except Exception as e:
- return error_response(500, f"获取密码历史失败: {str(e)}")
- @router.post("/users/change-password", response_model=dict)
- def change_password(
- body: dict,
- request: Request,
- db: Session = Depends(get_db),
- current_user: dict = Depends(get_current_user)
- ):
- """用户修改密码"""
- old_password = body.get("oldPassword")
- new_password = body.get("newPassword")
-
- if not old_password or not new_password:
- return error_response(400, "旧密码和新密码都不能为空")
-
- if len(new_password) < 6:
- return error_response(400, "新密码长度不能少于6位")
-
- user = user_crud.get(db, current_user.get("user_id"))
- if not user:
- return error_response(404, "用户不存在")
-
- if not verify_password(old_password, user.password_hash):
- return error_response(400, "旧密码错误")
-
- user.password_hash = get_password_hash(new_password)
- db.commit()
-
- ip_address = get_client_ip(request)
- password_history_crud.create_password_history(
- db,
- user_id=user.id,
- operation_type="modify",
- operator_id=user.id,
- operator_name=user.real_name,
- ip_address=ip_address
- )
-
- return success_response({"message": "密码修改成功"})
- # ============ 商品管理 ============
- @router.get("/products", response_model=dict)
- def list_products(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=1000, description="每页条数"),
- categoryId: Optional[str] = Query(None, description="分类ID"),
- keyword: Optional[str] = Query(None, description="关键词搜索"),
- isActive: Optional[bool] = Query(None, description="是否启用"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取商品列表"""
- products, total = product_crud.get_multi_with_filters(
- db,
- category_id=categoryId,
- keyword=keyword,
- is_active=isActive,
- skip=(page - 1) * pageSize,
- limit=pageSize,
- defer_detail_content=True,
- )
- category_ids = {p.category_id for p in products if p.category_id}
- unit_ids = {p.unit_id for p in products if p.unit_id}
- category_map: dict[str, str] = {}
- unit_map: dict[str, str] = {}
- if category_ids:
- rows = (
- db.query(BasProductCategory.id, BasProductCategory.name)
- .filter(BasProductCategory.id.in_(category_ids))
- .all()
- )
- category_map = {str(r.id): r.name for r in rows}
- if unit_ids:
- rows = (
- db.query(BasProductUnit.id, BasProductUnit.name)
- .filter(BasProductUnit.id.in_(unit_ids))
- .all()
- )
- unit_map = {str(r.id): r.name for r in rows}
- return success_response({
- "list": [{
- "id": p.id,
- "name": p.name,
- "skuCode": p.sku_code,
- "categoryId": p.category_id,
- "categoryName": category_map.get(str(p.category_id)) if p.category_id else None,
- "unitId": p.unit_id,
- "unitName": unit_map.get(str(p.unit_id)) if p.unit_id else None,
- "measureValue": _numeric_to_float_optional(getattr(p, "measure_value", None)),
- "description": p.description,
- # 列表不加载 detail_content(TEXT);详情见 GET /products/{id}
- "detailContent": None,
- "imageUrl": p.image_url,
- "priceCoefficient": _numeric_to_float_optional(getattr(p, "price_coefficient", None)) or 0.0,
- "isActive": p.is_active,
- "createAt": str(p.create_at) if p.create_at else None
- } for p in products],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/products/{product_id}", response_model=dict)
- def get_product(
- product_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取商品详情"""
- product = product_crud.get(db, product_id)
- if not product:
- return error_response(404, "商品不存在")
-
- category_name = None
- if product.category_id:
- cat = category_crud.get(db, product.category_id)
- category_name = cat.name if cat else None
-
- unit_name = None
- if product.unit_id:
- u = unit_crud.get(db, product.unit_id)
- unit_name = u.name if u else None
-
- return success_response({
- "id": product.id,
- "name": product.name,
- "skuCode": product.sku_code,
- "categoryId": product.category_id,
- "categoryName": category_name,
- "unitId": product.unit_id,
- "unitName": unit_name,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)),
- "description": product.description,
- "detailContent": product.detail_content,
- "imageUrl": product.image_url,
- "priceCoefficient": _numeric_to_float_optional(getattr(product, "price_coefficient", None)) or 0.0,
- "isActive": product.is_active,
- "createAt": str(product.create_at) if product.create_at else None
- })
- @router.get("/products/{product_id}/region-prices", response_model=dict)
- def get_product_region_prices(
- product_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """各启用区域下,当前生效价格清单中该商品的单价(无生效价则 price 为空)"""
- from app.crud.price_list import price_list_item_crud
- product = product_crud.get(db, product_id)
- if not product:
- return error_response(404, "商品不存在")
- regions = region_crud.get_active_all(db)
- admin_region_id = get_admin_region_filter(db, current_user)
- if admin_region_id:
- regions = [r for r in regions if r.id == admin_region_id]
- rows = []
- for r in regions:
- item = price_list_item_crud.get_product_price(db, r.id, product_id)
- if item:
- unit_name = None
- if item.unit_id:
- u = unit_crud.get(db, item.unit_id)
- unit_name = u.name if u else None
- rows.append({
- "regionId": r.id,
- "regionName": r.name,
- "price": float(item.price) if item.price is not None else None,
- "unitId": item.unit_id,
- "unitName": unit_name
- })
- else:
- rows.append({
- "regionId": r.id,
- "regionName": r.name,
- "price": None,
- "unitId": None,
- "unitName": None
- })
- return success_response({"list": rows})
- @router.post("/products", response_model=dict)
- def create_product(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """创建商品"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- try:
- product = product_crud.create(db, request, creator)
- except IntegrityError as e:
- db.rollback()
- err = str(getattr(e.orig, "args", e) or e)
- if "uk_sku_code" in err or "sku_code" in err.lower():
- return error_response(400, "SKU 编码已存在,请修改或点击「自动生成」")
- raise
- return success_response({
- "id": product.id,
- "name": product.name
- })
- @router.post("/products/upload-image", response_model=dict)
- async def upload_product_image(
- file: UploadFile = File(...),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """上传商品图片/轮播图(支持图片和视频)"""
- try:
- # 验证文件类型
- allowed_image_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif"}
- allowed_video_types = {"video/mp4", "video/quicktime", "video/webm", "video/x-msvideo"}
- allowed_types = allowed_image_types | allowed_video_types
- if file.content_type not in allowed_types:
- return error_response(400, "不支持的文件格式,支持:JPG、PNG、GIF、WebP、HEIC、MP4")
- # 验证文件大小 (图片10MB,视频100MB)
- max_size = 100 * 1024 * 1024 if file.content_type.startswith("video/") else 10 * 1024 * 1024
- content = b""
- chunk_size = 1024 * 1024
- while True:
- chunk = await file.read(chunk_size)
- if not chunk:
- break
- content += chunk
- if len(content) > max_size:
- return error_response(400, f"{'视频' if file.content_type.startswith('video/') else '图片'}大小不能超过{'100MB' if file.content_type.startswith('video/') else '10MB'}")
- await file.seek(0)
- # 确定文件后缀和上传目录
- if file.content_type.startswith("video/"):
- 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"
- folder = "products/banners"
- filename = f"video_{uuid.uuid4().hex[:8]}.{ext}"
- else:
- ext = file.filename.rsplit(".", 1)[-1] if "." in (file.filename or "") else "jpg"
- folder = "products"
- filename = file.filename or f"image_{uuid.uuid4().hex[:8]}.{ext}"
- # 上传文件
- url = storage.upload_file(content, filename, folder)
- return success_response({
- "url": url,
- "filename": file.filename,
- "contentType": file.content_type,
- "size": len(content)
- })
- except Exception as e:
- return error_response(500, f"上传失败: {str(e)}")
- @router.post("/products/upload-base64", response_model=dict)
- async def upload_product_image_base64(
- data: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """上传商品图片(Base64方式)"""
- try:
- base64_data = data.get("base64")
- if not base64_data:
- return error_response(400, "base64数据不能为空")
- # 去除前缀
- if "," in base64_data:
- base64_data = base64_data.split(",")[1]
- # 验证图片大小
- decoded_len = len(base64_data) * 3 // 4
- if decoded_len > 5 * 1024 * 1024:
- return error_response(400, "图片大小不能超过5MB")
- url = storage.upload_base64(base64_data, "product.png", "products")
- return success_response({
- "url": url,
- "filename": "product.png"
- })
- except Exception as e:
- return error_response(500, f"上传失败: {str(e)}")
- @router.put("/products/{product_id}", response_model=dict)
- def update_product(
- product_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """更新商品"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- product = product_crud.update_with_history(db, product_id, request, updater)
-
- if not product:
- return error_response(404, "商品不存在")
-
- return success_response({
- "id": product.id,
- "name": product.name,
- "skuCode": product.sku_code,
- "categoryId": product.category_id,
- "unitId": product.unit_id,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)),
- "description": product.description,
- "detailContent": product.detail_content,
- "imageUrl": product.image_url,
- "priceCoefficient": _numeric_to_float_optional(getattr(product, "price_coefficient", None)) or 0.0,
- "isActive": product.is_active
- })
- @router.delete("/products/{product_id}", response_model=dict)
- def delete_product(
- product_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """删除商品"""
- success = product_crud.soft_delete_with_history(db, product_id)
- if not success:
- return error_response(404, "商品不存在")
- return success_response({"id": product_id})
- # ============ 询价单管理 ============
- @router.get("/inquiries", response_model=dict)
- def list_inquiries(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- status: Optional[str] = Query(None, description="状态: draft/published/closed"),
- keyword: Optional[str] = Query(None, description="关键词搜索"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取询价单列表"""
- from app.crud.inquiry import inquiry_crud
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None:
- if regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- if not regionId and allowed_region_ids:
- regionId = sorted(allowed_region_ids)[0]
- inquiries, total = inquiry_crud.get_multi_with_filters(
- db,
- region_id=regionId,
- status=status,
- keyword=keyword,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- region_map = {}
- for inquiry in inquiries:
- if inquiry.region_id and inquiry.region_id not in region_map:
- region = region_crud.get(db, inquiry.region_id)
- if region:
- region_map[inquiry.region_id] = region.name
- return success_response({
- "list": [{
- "id": i.id,
- "regionId": i.region_id,
- "regionName": region_map.get(i.region_id),
- "title": i.title,
- "status": i.status,
- "publishTime": str(i.publish_time) if i.publish_time else None,
- "closeTime": str(i.close_time) if i.close_time else None,
- "remark": i.remark,
- "createAt": str(i.create_at) if i.create_at else None,
- "createName": i.create_name
- } for i in inquiries],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/inquiries/{inquiry_id}", response_model=dict)
- def get_inquiry(
- inquiry_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取询价单详情"""
- from app.crud.inquiry import inquiry_crud, inquiry_item_crud
- from app.crud.product import product_crud
- inquiry = inquiry_crud.get(db, inquiry_id)
- if not inquiry:
- return error_response(404, "询价单不存在")
- items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
- region_name = None
- if inquiry.region_id:
- region = region_crud.get(db, inquiry.region_id)
- region_name = region.name if region else None
- item_details = []
- for item in items:
- product = product_crud.get(db, item.product_id)
- category_name = None
- if product and product.category_id:
- cat = category_crud.get(db, product.category_id)
- category_name = cat.name if cat else None
- unit_name = None
- if product and product.unit_id:
- u = unit_crud.get(db, product.unit_id)
- unit_name = u.name if u else None
- item_details.append({
- "id": item.id,
- "productId": item.product_id,
- "productName": product.name if product else None,
- "categoryId": product.category_id if product else None,
- "categoryName": category_name,
- "unitName": unit_name,
- "description": product.description if product else None,
- "imageUrl": product.image_url if product else None,
- "createAt": str(item.create_at) if item.create_at else None
- })
- return success_response({
- "id": inquiry.id,
- "regionId": inquiry.region_id,
- "regionName": region_name,
- "title": inquiry.title,
- "status": inquiry.status,
- "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None,
- "closeTime": str(inquiry.close_time) if inquiry.close_time else None,
- "remark": inquiry.remark,
- "createAt": str(inquiry.create_at) if inquiry.create_at else None,
- "createName": inquiry.create_name,
- "items": item_details
- })
- @router.post("/inquiries", response_model=dict)
- def create_inquiry(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """创建询价单"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- from app.crud.inquiry import inquiry_crud, inquiry_item_crud
- creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- inquiry_data = {
- "region_id": request.get("regionId"),
- "title": request.get("title"),
- "remark": request.get("remark"),
- "status": "draft"
- }
- inquiry = inquiry_crud.create(db, inquiry_data, creator)
- product_ids = request.get("productIds", [])
- if product_ids:
- inquiry_item_crud.create_items(db, inquiry.id, product_ids, creator)
- region_name = None
- if inquiry.region_id:
- region = region_crud.get(db, inquiry.region_id)
- region_name = region.name if region else None
- return success_response({
- "id": inquiry.id,
- "regionId": inquiry.region_id,
- "regionName": region_name,
- "title": inquiry.title,
- "status": inquiry.status
- })
- @router.put("/inquiries/{inquiry_id}", response_model=dict)
- def update_inquiry(
- inquiry_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """更新询价单"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- from app.crud.inquiry import inquiry_crud, inquiry_item_crud
- inquiry = inquiry_crud.get(db, inquiry_id)
- if not inquiry:
- return error_response(404, "询价单不存在")
- if inquiry.status != "draft":
- return error_response(400, "只能编辑草稿状态的询价单")
- updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- update_data = {}
- if "title" in request:
- update_data["title"] = request["title"]
- if "remark" in request:
- update_data["remark"] = request["remark"]
- if update_data:
- inquiry = inquiry_crud.update_with_history(db, inquiry_id, update_data, updater)
- if "productIds" in request:
- inquiry_item_crud.update_items(db, inquiry_id, request["productIds"], updater)
- region_name = None
- if inquiry.region_id:
- region = region_crud.get(db, inquiry.region_id)
- region_name = region.name if region else None
- return success_response({
- "id": inquiry.id,
- "regionId": inquiry.region_id,
- "regionName": region_name,
- "title": inquiry.title,
- "status": inquiry.status
- })
- @router.delete("/inquiries/{inquiry_id}", response_model=dict)
- def delete_inquiry(
- inquiry_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """删除询价单"""
- from app.crud.inquiry import inquiry_crud, inquiry_item_crud
- inquiry = inquiry_crud.get(db, inquiry_id)
- if not inquiry:
- return error_response(404, "询价单不存在")
- if inquiry.status == "published":
- return error_response(400, "已发布的询价单不能删除")
- inquiry_item_crud.delete_by_inquiry(db, inquiry_id)
- inquiry_crud.soft_delete_with_history(db, inquiry_id)
- return success_response({"id": inquiry_id})
- @router.post("/inquiries/{inquiry_id}/publish", response_model=dict)
- def publish_inquiry(
- inquiry_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """发布询价单"""
- from app.crud.inquiry import inquiry_crud, inquiry_item_crud
- from app.models.models import BizInquiry
- from sqlalchemy import and_
- inquiry = inquiry_crud.get(db, inquiry_id)
- if not inquiry:
- return error_response(404, "询价单不存在")
- if inquiry.status != "draft":
- return error_response(400, "只能发布草稿状态的询价单")
- items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
- if not items:
- return error_response(400, "询价单必须包含至少一个商品")
- # 同区域仅允许一个发布中的询价单(并发下尽量避免双发布)
- try:
- # 锁住同区域发布中的记录(若存在)与当前记录
- db.query(BizInquiry).filter(
- and_(
- BizInquiry.region_id == inquiry.region_id,
- BizInquiry.status == "published",
- BizInquiry.is_del == 0
- )
- ).with_for_update().all()
- db.query(BizInquiry).filter(BizInquiry.id == inquiry_id).with_for_update().all()
- except Exception:
- # with_for_update 在某些配置下可能不生效,但不影响基本校验
- pass
- existing = inquiry_crud.get_current_published_by_region(db, inquiry.region_id)
- if existing and existing.id != inquiry_id:
- return error_response(400, "该区域已存在发布中的询价单,请先取消发布当前询价单后再发布新的询价单")
- inquiry = inquiry_crud.publish(db, inquiry_id)
- return success_response({
- "id": inquiry.id,
- "status": inquiry.status,
- "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None
- })
- @router.post("/inquiries/{inquiry_id}/unpublish", response_model=dict)
- def unpublish_inquiry(
- inquiry_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """取消发布询价单(published -> draft)"""
- from app.crud.inquiry import inquiry_crud
- inquiry = inquiry_crud.get(db, inquiry_id)
- if not inquiry:
- return error_response(404, "询价单不存在")
- if inquiry.status != "published":
- return error_response(400, "只能取消发布已发布状态的询价单")
- inquiry = inquiry_crud.unpublish(db, inquiry_id)
- return success_response({
- "id": inquiry.id,
- "status": inquiry.status
- })
- @router.post("/inquiries/{inquiry_id}/close", response_model=dict)
- def close_inquiry(
- inquiry_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """关闭询价单"""
- from app.crud.inquiry import inquiry_crud
- inquiry = inquiry_crud.get(db, inquiry_id)
- if not inquiry:
- return error_response(404, "询价单不存在")
- if inquiry.status != "published":
- return error_response(400, "只能关闭已发布的询价单")
- inquiry = inquiry_crud.close(db, inquiry_id)
- return success_response({
- "id": inquiry.id,
- "status": inquiry.status,
- "closeTime": str(inquiry.close_time) if inquiry.close_time else None
- })
- # ============ 报价管理(无审批) ============
- @router.get("/quotations", response_model=dict)
- def list_quotations(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- inquiryId: Optional[str] = Query(None, description="询价单ID"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- status: Optional[str] = Query(None, description="状态: submitted/selected"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取报价单列表"""
- from app.crud.quotation import quotation_crud, quotation_item_crud
- from app.crud.inquiry import inquiry_crud
- from app.crud.user import user_crud
- # 区域管理员只能查看自己区域的报价单
- admin_region_id = get_admin_region_filter(db, current_user)
- if admin_region_id and not regionId:
- regionId = admin_region_id
- elif admin_region_id and regionId != admin_region_id:
- return success_response({
- "list": [],
- "total": 0,
- "page": page,
- "pageSize": pageSize,
- "totalPages": 0
- })
- quotations, total = quotation_crud.get_multi_with_filters(
- db,
- inquiry_id=inquiryId,
- region_id=regionId,
- status=status,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- region_map = {}
- inquiry_map = {}
- supplier_map = {}
- for q in quotations:
- if q.region_id and q.region_id not in region_map:
- region = region_crud.get(db, q.region_id)
- region_map[q.region_id] = region.name if region else None
- if q.inquiry_id and q.inquiry_id not in inquiry_map:
- inquiry = inquiry_crud.get(db, q.inquiry_id)
- inquiry_map[q.inquiry_id] = inquiry.title if inquiry else None
- if q.supplier_id and q.supplier_id not in supplier_map:
- supplier = user_crud.get(db, q.supplier_id)
- supplier_map[q.supplier_id] = supplier.real_name if supplier else None
- return success_response({
- "list": [{
- "id": q.id,
- "inquiryId": q.inquiry_id,
- "inquiryTitle": inquiry_map.get(q.inquiry_id),
- "supplierId": q.supplier_id,
- "supplierName": supplier_map.get(q.supplier_id),
- "regionId": q.region_id,
- "regionName": region_map.get(q.region_id),
- "status": q.status,
- "totalAmount": float(q.total_amount) if q.total_amount else 0,
- "submitTime": str(q.submit_time) if q.submit_time else None,
- "createAt": str(q.create_at) if q.create_at else None
- } for q in quotations],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/quotations/{quotation_id}", response_model=dict)
- def get_quotation(
- quotation_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取报价单详情"""
- from app.crud.quotation import quotation_crud, quotation_item_crud
- from app.crud.inquiry import inquiry_crud
- from app.crud.user import user_crud
- from app.crud.product import product_crud
- from app.crud.category import category_crud
- from app.models.models import BizPriceList, BizPriceListItem, BizInquiryItem
- from sqlalchemy import and_
- quotation = quotation_crud.get(db, quotation_id)
- if not quotation:
- return error_response(404, "报价单不存在")
- # 询价单商品作为主表,确保供应商未报价商品也能展示
- inquiry_items = db.query(BizInquiryItem).filter(
- and_(
- BizInquiryItem.inquiry_id == quotation.inquiry_id,
- BizInquiryItem.is_del == 0
- )
- ).order_by(BizInquiryItem.create_at.asc()).all()
- # 报价单项作为从表(按 product_id 映射)
- items = quotation_item_crud.get_by_quotation(db, quotation_id)
- q_item_by_pid: dict[str, object] = {}
- for it in items:
- pid = str(getattr(it, "product_id", "") or "")
- if pid and pid not in q_item_by_pid:
- q_item_by_pid[pid] = it
- # 若该报价已被选中并生成价格清单,则回显调价系数/最终售价(用于运营端查看)
- pl = db.query(BizPriceList).filter(
- and_(
- BizPriceList.quotation_id == quotation_id,
- BizPriceList.is_del == 0
- )
- ).order_by(BizPriceList.version.desc(), BizPriceList.published_time.desc()).first()
- # key: product_id, value: pricing_coefficient(%) used at publish-time
- # NOTE: older price lists may have NULL pricing_coefficient; we keep None to allow fallback to product.price_coefficient.
- coeff_map: dict[str, Optional[float]] = {}
- final_price_map: dict[str, float] = {}
- if pl:
- pl_items = db.query(BizPriceListItem).filter(
- and_(
- BizPriceListItem.price_list_id == pl.id,
- BizPriceListItem.is_del == 0
- )
- ).all()
- for pit in pl_items:
- pid = str(pit.product_id or "")
- if not pid:
- continue
- coeff_map[pid] = float(pit.pricing_coefficient) if getattr(pit, "pricing_coefficient", None) is not None else None
- final_price_map[pid] = float(pit.price) if pit.price else 0
- region_name = None
- if quotation.region_id:
- region = region_crud.get(db, quotation.region_id)
- region_name = region.name if region else None
- inquiry_title = None
- if quotation.inquiry_id:
- inquiry = inquiry_crud.get(db, quotation.inquiry_id)
- inquiry_title = inquiry.title if inquiry else None
- supplier_name = None
- supplier_phone = None
- if quotation.supplier_id:
- supplier = user_crud.get(db, quotation.supplier_id)
- if supplier:
- supplier_name = supplier.real_name
- supplier_phone = supplier.phone
- item_details = []
- for inq_item in inquiry_items:
- pid = str(getattr(inq_item, "product_id", "") or "")
- q_item = q_item_by_pid.get(pid)
- product = product_crud.get(db, pid) if pid else None
- default_coeff = _numeric_to_float_optional(getattr(product, "price_coefficient", None)) if product else None
- default_coeff = float(default_coeff) if default_coeff is not None else 0.0
- category_name = None
- if product and getattr(product, "category_id", None):
- category = category_crud.get(db, product.category_id)
- category_name = category.name if category else None
- unit_id = None
- unit_name = None
- # unit 优先取报价单项(更准确),否则回退商品默认 unit_id
- if q_item and getattr(q_item, "unit_id", None):
- unit_id = getattr(q_item, "unit_id", None)
- elif product and getattr(product, "unit_id", None):
- unit_id = getattr(product, "unit_id", None)
- if unit_id:
- u = unit_crud.get(db, unit_id)
- unit_name = u.name if u else None
- supplier_price = float(getattr(q_item, "price", 0) or 0) if q_item else 0
- pricing_coeff = coeff_map.get(pid, None)
- if pricing_coeff is None:
- pricing_coeff = default_coeff
- item_details.append({
- "id": getattr(q_item, "id", None) if q_item else None,
- "productId": pid,
- "productName": product.name if product else None,
- "imageUrl": getattr(product, "image_url", None) if product else None,
- "categoryName": category_name,
- "unitId": unit_id,
- "unitName": unit_name,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
- "supplierPrice": supplier_price,
- "pricingCoefficient": pricing_coeff,
- "finalPrice": final_price_map.get(pid, 0),
- # 兼容旧前端字段:price 仍返回供应商报价
- "price": supplier_price
- })
- return success_response({
- "id": quotation.id,
- "inquiryId": quotation.inquiry_id,
- "inquiryTitle": inquiry_title,
- "supplierId": quotation.supplier_id,
- "supplierName": supplier_name,
- "supplierPhone": supplier_phone,
- "regionId": quotation.region_id,
- "regionName": region_name,
- "status": quotation.status,
- "totalAmount": float(quotation.total_amount) if quotation.total_amount else 0,
- "submitTime": str(quotation.submit_time) if quotation.submit_time else None,
- "createAt": str(quotation.create_at) if quotation.create_at else None,
- "items": item_details
- })
- # =========================================
- # 询价单报价聚合查询(用于询价报价管理页)
- # =========================================
- @router.get("/inquiries/{inquiry_id}/quotations/latest-by-supplier", response_model=dict)
- def list_latest_quotations_by_supplier(
- inquiry_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """按询价单聚合:每个供应商最新一次报价(包含未报价供应商)"""
- from app.models.models import (
- BizInquiry,
- BizQuotation,
- SysUser,
- BizInquiryItem,
- BizQuotationItem,
- BasProduct,
- BasProductCategory,
- BasProductUnit,
- )
- from sqlalchemy import and_
- inquiry = db.query(BizInquiry).filter(and_(BizInquiry.id == inquiry_id, BizInquiry.is_del == 0)).first()
- if not inquiry:
- return error_response(404, "询价单不存在")
- # 询价单商品作为主表:后续给每个供应商补齐这些商品行
- inquiry_items = db.query(BizInquiryItem).filter(
- and_(
- BizInquiryItem.inquiry_id == inquiry_id,
- BizInquiryItem.is_del == 0
- )
- ).order_by(BizInquiryItem.create_at.asc()).all()
- # 该区域全部供应商(包含未报价)
- suppliers = db.query(SysUser).filter(
- and_(
- SysUser.role == "supplier",
- SysUser.region_id == inquiry.region_id,
- SysUser.is_del == 0,
- SysUser.is_active == True
- )
- ).order_by(SysUser.create_at.asc()).all()
- # 查询该询价单下全部报价单,然后在内存中取每个供应商最新一条
- quotations = db.query(BizQuotation).filter(
- and_(
- BizQuotation.inquiry_id == inquiry_id,
- BizQuotation.is_del == 0
- )
- ).order_by(BizQuotation.create_at.desc()).all()
- latest_by_supplier: dict[str, BizQuotation] = {}
- for q in quotations:
- if q.supplier_id not in latest_by_supplier:
- latest_by_supplier[q.supplier_id] = q
- # 询价商品维度预取(避免循环内 N+1)
- inquiry_product_ids = [str(getattr(x, "product_id", "") or "") for x in inquiry_items if getattr(x, "product_id", None)]
- inquiry_product_ids = [x for x in inquiry_product_ids if x]
- products = []
- if inquiry_product_ids:
- products = db.query(BasProduct).filter(
- and_(BasProduct.id.in_(inquiry_product_ids), BasProduct.is_del == 0)
- ).all()
- product_map: dict[str, object] = {str(getattr(p, "id", "")): p for p in products}
- category_ids = list({
- str(getattr(p, "category_id", "") or "")
- for p in products if getattr(p, "category_id", None)
- })
- categories = []
- if category_ids:
- categories = db.query(BasProductCategory).filter(
- and_(BasProductCategory.id.in_(category_ids), BasProductCategory.is_del == 0)
- ).all()
- category_map: dict[str, str] = {
- str(getattr(c, "id", "")): str(getattr(c, "name", "") or "")
- for c in categories
- }
- # 最新报价单项批量预取(按 quotation_id + product_id 建映射)
- latest_qids = [str(getattr(q, "id", "") or "") for q in latest_by_supplier.values() if getattr(q, "id", None)]
- quotation_items_by_qid: dict[str, dict[str, object]] = {}
- unit_ids = set(
- str(getattr(p, "unit_id", "") or "")
- for p in products if getattr(p, "unit_id", None)
- )
- if latest_qids:
- q_items = db.query(BizQuotationItem).filter(
- and_(BizQuotationItem.quotation_id.in_(latest_qids), BizQuotationItem.is_del == 0)
- ).all()
- for it in q_items:
- qid = str(getattr(it, "quotation_id", "") or "")
- pid = str(getattr(it, "product_id", "") or "")
- if not qid or not pid:
- continue
- quotation_items_by_qid.setdefault(qid, {})
- if pid not in quotation_items_by_qid[qid]:
- quotation_items_by_qid[qid][pid] = it
- if getattr(it, "unit_id", None):
- unit_ids.add(str(getattr(it, "unit_id", "") or ""))
- units = []
- clean_unit_ids = [x for x in unit_ids if x]
- if clean_unit_ids:
- units = db.query(BasProductUnit).filter(
- and_(BasProductUnit.id.in_(clean_unit_ids), BasProductUnit.is_del == 0)
- ).all()
- unit_map: dict[str, str] = {
- str(getattr(u, "id", "")): str(getattr(u, "name", "") or "")
- for u in units
- }
- # 组装返回:供应商维度 + 最新报价(如无则为空)
- result = []
- for s in suppliers:
- q = latest_by_supplier.get(s.id)
- quotation_items = []
- q_item_by_pid: dict[str, object] = {}
- if q and getattr(q, "id", None):
- q_item_by_pid = quotation_items_by_qid.get(str(q.id), {})
- # LEFT JOIN:以询价单商品为主,补齐未报价商品
- for inq_item in inquiry_items:
- pid = str(getattr(inq_item, "product_id", "") or "")
- it = q_item_by_pid.get(pid)
- product = product_map.get(pid)
- category_name = category_map.get(str(getattr(product, "category_id", "") or ""), None) if product else None
- unit_id = None
- if it and getattr(it, "unit_id", None):
- unit_id = getattr(it, "unit_id", None)
- elif product and getattr(product, "unit_id", None):
- unit_id = getattr(product, "unit_id", None)
- unit_name = unit_map.get(str(unit_id or ""), None) if unit_id else None
- price_val = None
- if it is not None:
- try:
- price_val = float(getattr(it, "price", 0) or 0)
- except Exception:
- price_val = 0
- quotation_items.append({
- "id": getattr(it, "id", None) if it else None,
- "productId": pid,
- "productName": product.name if product else None,
- "categoryName": category_name,
- "unitId": unit_id,
- "unitName": unit_name,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
- "imageUrl": getattr(product, "image_url", None) if product else None,
- # 没报价时保持 null,便于前端区分“未报价”与“0元”
- "price": price_val
- })
- result.append({
- "supplierId": s.id,
- "supplierName": s.real_name or s.username,
- "supplierPhone": s.phone,
- # 即使供应商未报价,也返回 quotation.items(=询价单全部商品),用于前端完整展示商品行
- "quotation": {
- "id": q.id if q else None,
- "status": q.status if q else None,
- "totalAmount": float(q.total_amount) if (q and q.total_amount) else 0,
- "submitTime": str(q.submit_time) if (q and q.submit_time) else None,
- "createAt": str(q.create_at) if (q and q.create_at) else None,
- "items": quotation_items
- }
- })
- return success_response({
- "inquiryId": inquiry_id,
- "regionId": inquiry.region_id,
- "list": result,
- "total": len(result)
- })
- @router.get("/inquiries/{inquiry_id}/quotations/history", response_model=dict)
- def list_quotation_history_by_inquiry(
- inquiry_id: str,
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- supplierId: Optional[str] = Query(None, description="供应商ID"),
- status: Optional[str] = Query(None, description="状态: submitted/selected"),
- startDate: Optional[str] = Query(None, description="开始日期(ISO)"),
- endDate: Optional[str] = Query(None, description="结束日期(ISO)"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """按询价单查看全部报价单(第三个tab)"""
- from app.models.models import BizQuotation, BizInquiry, BizQuotationItem, BizInquiryItem
- from app.crud.quotation import quotation_crud
- from app.crud.user import user_crud
- from sqlalchemy import and_
- from datetime import datetime
- inquiry = db.query(BizInquiry).filter(and_(BizInquiry.id == inquiry_id, BizInquiry.is_del == 0)).first()
- if not inquiry:
- return error_response(404, "询价单不存在")
- query = db.query(BizQuotation).filter(
- and_(
- BizQuotation.is_del == 0,
- BizQuotation.inquiry_id == inquiry_id,
- BizQuotation.region_id == inquiry.region_id
- )
- )
- if supplierId:
- query = query.filter(BizQuotation.supplier_id == supplierId)
- if status:
- query = query.filter(BizQuotation.status == status)
- def _parse_dt(v: Optional[str]) -> Optional[datetime]:
- if not v:
- return None
- try:
- return datetime.fromisoformat(v.replace("Z", "+00:00"))
- except Exception:
- return None
- sd = _parse_dt(startDate)
- ed = _parse_dt(endDate)
- if sd:
- query = query.filter(BizQuotation.create_at >= sd)
- if ed:
- query = query.filter(BizQuotation.create_at <= ed)
- total = query.count()
- quotations = query.order_by(BizQuotation.create_at.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
- supplier_map = {}
- for q in quotations:
- if q.supplier_id and q.supplier_id not in supplier_map:
- u = user_crud.get(db, q.supplier_id)
- supplier_map[q.supplier_id] = u.real_name if u else None
- q_ids = [q.id for q in quotations]
- partial_ids: set[str] = set()
- inquiry_pids = [
- r[0]
- for r in db.query(BizInquiryItem.product_id)
- .filter(BizInquiryItem.inquiry_id == inquiry_id, BizInquiryItem.is_del == 0)
- .all()
- ]
- expected = set(inquiry_pids)
- if q_ids:
- item_rows = (
- db.query(BizQuotationItem.quotation_id, BizQuotationItem.product_id, BizQuotationItem.price)
- .filter(BizQuotationItem.quotation_id.in_(q_ids), BizQuotationItem.is_del == 0)
- .all()
- )
- by_quotation: dict[str, dict[str, object]] = {}
- for qid, pid, price in item_rows:
- by_quotation.setdefault(qid, {})[pid] = price
- for qid in q_ids:
- pmap = by_quotation.get(qid, {})
- if expected:
- for pid in expected:
- pr = pmap.get(pid)
- try:
- pv = float(pr) if pr is not None else 0.0
- except (TypeError, ValueError):
- pv = 0.0
- if pv <= 0:
- partial_ids.add(qid)
- break
- else:
- for pr in pmap.values():
- try:
- pv = float(pr) if pr is not None else 0.0
- except (TypeError, ValueError):
- pv = 0.0
- if pv <= 0:
- partial_ids.add(qid)
- break
- return success_response({
- "list": [{
- "id": q.id,
- "inquiryId": q.inquiry_id,
- "supplierId": q.supplier_id,
- "supplierName": supplier_map.get(q.supplier_id),
- "regionId": q.region_id,
- "status": q.status,
- "totalAmount": float(q.total_amount) if q.total_amount else 0,
- "submitTime": str(q.submit_time) if q.submit_time else None,
- "createAt": str(q.create_at) if q.create_at else None,
- "partialPriceMissing": q.id in partial_ids
- } for q in quotations],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/inquiries/{inquiry_id}/quotations/selected", response_model=dict)
- def get_selected_quotation_for_inquiry(
- inquiry_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取某询价单当前被选中的报价(若已生成价格清单,则从价格清单反查)"""
- from app.models.models import (
- BizInquiry,
- BizQuotation,
- BizPriceList,
- BizPriceListItem,
- BizQuotationItem,
- BizInquiryItem,
- BasProduct,
- BasProductCategory,
- BasProductUnit,
- )
- from sqlalchemy import and_
- from app.crud.user import user_crud
- inquiry = db.query(BizInquiry).filter(and_(BizInquiry.id == inquiry_id, BizInquiry.is_del == 0)).first()
- if not inquiry:
- return error_response(404, "询价单不存在")
- # 询价单商品清单(作为主表,确保完整展示)
- inquiry_items = db.query(BizInquiryItem).filter(
- and_(
- BizInquiryItem.inquiry_id == inquiry_id,
- BizInquiryItem.is_del == 0
- )
- ).order_by(BizInquiryItem.create_at.asc()).all()
- inquiry_product_ids = [str(getattr(x, "product_id", "") or "") for x in inquiry_items if getattr(x, "product_id", None)]
- inquiry_product_ids = [x for x in inquiry_product_ids if x]
- products = []
- if inquiry_product_ids:
- products = db.query(BasProduct).filter(
- and_(BasProduct.id.in_(inquiry_product_ids), BasProduct.is_del == 0)
- ).all()
- product_map: dict[str, object] = {str(getattr(p, "id", "")): p for p in products}
- category_ids = list({
- str(getattr(p, "category_id", "") or "")
- for p in products if getattr(p, "category_id", None)
- })
- categories = []
- if category_ids:
- categories = db.query(BasProductCategory).filter(
- and_(BasProductCategory.id.in_(category_ids), BasProductCategory.is_del == 0)
- ).all()
- category_map: dict[str, str] = {
- str(getattr(c, "id", "")): str(getattr(c, "name", "") or "")
- for c in categories
- }
- # 优先从价格清单查来源(允许后续价格已被新的询价覆盖 active)
- pl = db.query(BizPriceList).filter(
- and_(
- BizPriceList.inquiry_id == inquiry_id,
- BizPriceList.is_del == 0
- )
- ).order_by(BizPriceList.version.desc(), BizPriceList.published_time.desc()).first()
- if not pl:
- # 兜底:直接查 selected 报价
- q = db.query(BizQuotation).filter(
- and_(
- BizQuotation.inquiry_id == inquiry_id,
- BizQuotation.status == "selected",
- BizQuotation.is_del == 0
- )
- ).order_by(BizQuotation.create_at.desc()).first()
- if not q:
- # 未选择任何供应商报价时,也返回询价单商品清单(用于前端完整展示)
- item_details = []
- for inq_item in inquiry_items:
- pid = str(getattr(inq_item, "product_id", "") or "")
- product = product_crud.get(db, pid) if pid else None
- default_coeff = _numeric_to_float_optional(getattr(product, "price_coefficient", None)) if product else None
- default_coeff = float(default_coeff) if default_coeff is not None else 0.0
- category_name = None
- if product and getattr(product, "category_id", None):
- cat = category_crud.get(db, product.category_id)
- category_name = cat.name if cat else None
- unit_id = getattr(product, "unit_id", None) if product else None
- unit_name = None
- if unit_id:
- u = unit_crud.get(db, unit_id)
- unit_name = u.name if u else None
- item_details.append({
- "productId": pid,
- "productName": product.name if product else None,
- "skuCode": getattr(product, "sku_code", None) if product else None,
- "description": getattr(product, "description", None) if product else None,
- "imageUrl": getattr(product, "image_url", None) if product else None,
- "categoryName": category_name,
- "unitId": unit_id,
- "unitName": unit_name,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
- # 未报价:返回 0 / 0,前端可按需展示为 '-'
- "supplierPrice": 0,
- "price": 0,
- "pricingCoefficient": default_coeff
- })
- return success_response({
- "quotationId": None,
- "quotationCreateAt": None,
- "quotationSubmitTime": None,
- "supplierId": None,
- "supplierName": None,
- "supplierLogoUrl": None,
- "items": item_details
- })
- supplier = user_crud.get(db, q.supplier_id) if q.supplier_id else None
- # 兜底分支也补齐询价单商品行:supplierPrice 从报价项拿,最终售价未知则为 0
- supplier_price_map: dict[str, float] = {}
- q_items = db.query(BizQuotationItem).filter(
- and_(BizQuotationItem.quotation_id == q.id, BizQuotationItem.is_del == 0)
- ).all()
- for qi in q_items:
- if not qi.product_id:
- continue
- supplier_price_map[str(qi.product_id)] = float(qi.price) if qi.price else 0
- item_details = []
- for inq_item in inquiry_items:
- pid = str(getattr(inq_item, "product_id", "") or "")
- product = product_crud.get(db, pid) if pid else None
- default_coeff = _numeric_to_float_optional(getattr(product, "price_coefficient", None)) if product else None
- default_coeff = float(default_coeff) if default_coeff is not None else 0.0
- category_name = None
- if product and getattr(product, "category_id", None):
- cat = category_crud.get(db, product.category_id)
- category_name = cat.name if cat else None
- unit_id = getattr(product, "unit_id", None) if product else None
- unit_name = None
- if unit_id:
- u = unit_crud.get(db, unit_id)
- unit_name = u.name if u else None
- item_details.append({
- "productId": pid,
- "productName": product.name if product else None,
- "skuCode": getattr(product, "sku_code", None) if product else None,
- "description": getattr(product, "description", None) if product else None,
- "imageUrl": getattr(product, "image_url", None) if product else None,
- "categoryName": category_name,
- "unitId": unit_id,
- "unitName": unit_name,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
- # 未报价商品返回 0(兜底分支无法区分 null/0;主流程会更准确)
- "supplierPrice": supplier_price_map.get(pid, 0),
- "price": 0,
- "pricingCoefficient": default_coeff
- })
- return success_response({
- "quotationId": q.id,
- "quotationCreateAt": str(q.create_at) if getattr(q, "create_at", None) else None,
- "quotationSubmitTime": str(q.submit_time) if getattr(q, "submit_time", None) else None,
- "supplierId": q.supplier_id,
- "supplierName": supplier.real_name if supplier else None,
- "supplierLogoUrl": getattr(supplier, "avatar_url", None) if supplier else None,
- "items": item_details
- })
- supplier = user_crud.get(db, pl.supplier_id) if pl.supplier_id else None
- quotation_create_at = None
- quotation_submit_time = None
- if pl.quotation_id:
- q = db.query(BizQuotation).filter(
- and_(
- BizQuotation.id == pl.quotation_id,
- BizQuotation.is_del == 0
- )
- ).first()
- if q:
- quotation_create_at = str(q.create_at) if getattr(q, "create_at", None) else None
- quotation_submit_time = str(q.submit_time) if getattr(q, "submit_time", None) else None
- items = db.query(BizPriceListItem).filter(
- and_(BizPriceListItem.price_list_id == pl.id, BizPriceListItem.is_del == 0)
- ).all()
- # price list item 按 product_id 映射(LEFT JOIN)
- pl_item_by_pid: dict[str, object] = {}
- for it in items:
- pid = str(getattr(it, "product_id", "") or "")
- if pid and pid not in pl_item_by_pid:
- pl_item_by_pid[pid] = it
- supplier_price_map: dict[str, float] = {}
- if pl.quotation_id:
- q_items = db.query(BizQuotationItem).filter(
- and_(BizQuotationItem.quotation_id == pl.quotation_id, BizQuotationItem.is_del == 0)
- ).all()
- for qi in q_items:
- if not qi.product_id:
- continue
- supplier_price_map[str(qi.product_id)] = float(qi.price) if qi.price else 0
- unit_ids = {
- str(getattr(p, "unit_id", "") or "")
- for p in products if getattr(p, "unit_id", None)
- }
- for pit in items:
- if getattr(pit, "unit_id", None):
- unit_ids.add(str(getattr(pit, "unit_id", "") or ""))
- units = []
- clean_unit_ids = [x for x in unit_ids if x]
- if clean_unit_ids:
- units = db.query(BasProductUnit).filter(
- and_(BasProductUnit.id.in_(clean_unit_ids), BasProductUnit.is_del == 0)
- ).all()
- unit_map: dict[str, str] = {
- str(getattr(u, "id", "")): str(getattr(u, "name", "") or "")
- for u in units
- }
- item_details = []
- for inq_item in inquiry_items:
- pid = str(getattr(inq_item, "product_id", "") or "")
- it = pl_item_by_pid.get(pid)
- product = product_map.get(pid)
- default_coeff = _numeric_to_float_optional(getattr(product, "price_coefficient", None)) if product else None
- default_coeff = float(default_coeff) if default_coeff is not None else 0.0
- category_name = category_map.get(str(getattr(product, "category_id", "") or ""), None) if product else None
- unit_id = None
- if it and getattr(it, "unit_id", None):
- unit_id = getattr(it, "unit_id", None)
- elif product and getattr(product, "unit_id", None):
- unit_id = getattr(product, "unit_id", None)
- unit_name = unit_map.get(str(unit_id or ""), None) if unit_id else None
- item_details.append({
- "productId": pid,
- "productName": product.name if product else None,
- "skuCode": getattr(product, "sku_code", None) if product else None,
- "description": getattr(product, "description", None) if product else None,
- "imageUrl": getattr(product, "image_url", None) if product else None,
- "categoryName": category_name,
- "unitId": unit_id,
- "unitName": unit_name,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
- "supplierPrice": supplier_price_map.get(pid, 0),
- # 最终售价来自 price list item;未生成/不存在时返回 0
- "price": float(getattr(it, "price", 0) or 0) if it else 0,
- "pricingCoefficient": float(getattr(it, "pricing_coefficient", 0)) if (it and getattr(it, "pricing_coefficient", None) is not None) else default_coeff
- })
- return success_response({
- "priceListId": pl.id,
- "priceVersion": pl.version,
- "quotationId": pl.quotation_id,
- "quotationCreateAt": quotation_create_at,
- "quotationSubmitTime": quotation_submit_time,
- "supplierId": pl.supplier_id,
- "supplierName": supplier.real_name if supplier else None,
- "supplierLogoUrl": getattr(supplier, "avatar_url", None) if supplier else None,
- "items": item_details
- })
- @router.get("/quotation-compare", response_model=dict)
- def compare_quotations(
- inquiryId: str = Query(..., description="询价单ID"),
- supplierIds: str = Query(..., description="供应商ID,逗号分隔"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """报价对比(按询价单+供应商)"""
- from app.crud.quotation import quotation_crud, quotation_item_crud
- supplier_id_list = [x.strip() for x in supplierIds.split(",") if x.strip()]
- if len(supplier_id_list) < 2:
- return error_response(400, "至少选择两个供应商")
- quotations, _ = quotation_crud.get_multi_with_filters(
- db,
- inquiry_id=inquiryId,
- skip=0,
- limit=500
- )
- quotations = [q for q in quotations if q.supplier_id in supplier_id_list]
- if not quotations:
- return success_response([])
- # 汇总每个商品在不同供应商下的价格
- compare_map = {}
- for q in quotations:
- items = quotation_item_crud.get_by_quotation(db, q.id)
- for item in items:
- product = product_crud.get(db, item.product_id)
- unit_name = None
- if item.unit_id:
- u = unit_crud.get(db, item.unit_id)
- unit_name = u.name if u else None
- key = f"{item.product_id}:{item.unit_id or ''}"
- row = compare_map.get(key)
- if not row:
- row = {
- "productId": item.product_id,
- "productName": product.name if product else item.product_id,
- "unitId": item.unit_id,
- "unitName": unit_name,
- "prices": {},
- "lowestPrice": None,
- "highestPrice": None
- }
- compare_map[key] = row
- price_value = float(item.price) if item.price is not None else 0
- row["prices"][q.supplier_id] = price_value
- result = []
- for _, row in compare_map.items():
- price_values = [v for v in row["prices"].values() if v is not None]
- if price_values:
- row["lowestPrice"] = min(price_values)
- row["highestPrice"] = max(price_values)
- else:
- row["lowestPrice"] = 0
- row["highestPrice"] = 0
- result.append(row)
- result.sort(key=lambda x: (x.get("productName") or "", x.get("unitName") or ""))
- return success_response(result)
- @router.post("/quotations/{quotation_id}/approve", response_model=dict)
- def approve_quotation(
- quotation_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """报价无需审核(接口已停用)"""
- return error_response(410, "报价无需审核:请在“询价报价管理-最新报价对比”中直接选中供应商生效价格")
- @router.post("/quotations/{quotation_id}/reject", response_model=dict)
- def reject_quotation(
- quotation_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """报价无需审核(接口已停用)"""
- return error_response(410, "报价无需审核:请在“询价报价管理-最新报价对比”中直接选中供应商生效价格")
- # =========================================
- # 价格清单管理 API (Web端)
- # =========================================
- @router.get("/price-lists", response_model=dict)
- def list_price_lists(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- status: Optional[str] = Query(None, description="状态: draft/pending/active/expired"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取价格清单列表"""
- from app.crud.price_list import price_list_crud
- from app.crud.user import user_crud
- # 区域管理员只能查看自己区域的价格清单
- admin_region_id = get_admin_region_filter(db, current_user)
- if admin_region_id and not regionId:
- regionId = admin_region_id
- elif admin_region_id and regionId != admin_region_id:
- return success_response({
- "list": [],
- "total": 0,
- "page": page,
- "pageSize": pageSize,
- "totalPages": 0
- })
- price_lists, total = price_list_crud.get_multi_with_filters(
- db,
- region_id=regionId,
- status=status,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- region_map = {}
- supplier_map = {}
- publisher_map = {}
- for pl in price_lists:
- if pl.region_id and pl.region_id not in region_map:
- region = region_crud.get(db, pl.region_id)
- region_map[pl.region_id] = region.name if region else None
- if pl.supplier_id and pl.supplier_id not in supplier_map:
- supplier = user_crud.get(db, pl.supplier_id)
- supplier_map[pl.supplier_id] = supplier.real_name if supplier else None
- if pl.published_by and pl.published_by not in publisher_map:
- publisher = user_crud.get(db, pl.published_by)
- publisher_map[pl.published_by] = publisher.real_name if publisher else None
- return success_response({
- "list": [{
- "id": pl.id,
- "regionId": pl.region_id,
- "regionName": region_map.get(pl.region_id),
- "supplierId": pl.supplier_id,
- "supplierName": supplier_map.get(pl.supplier_id),
- "version": pl.version,
- "status": pl.status,
- "effectiveTime": str(pl.effective_time) if pl.effective_time else None,
- "publishedBy": pl.published_by,
- "publishedByName": publisher_map.get(pl.published_by),
- "publishedTime": str(pl.published_time) if pl.published_time else None,
- "remark": pl.remark,
- "createAt": str(pl.create_at) if pl.create_at else None
- } for pl in price_lists],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.post("/price-lists", response_model=dict)
- def create_price_list(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """创建价格清单"""
- from app.crud.price_list import price_list_crud
- from datetime import datetime
- region_id = request.get("regionId")
- supplier_id = request.get("supplierId")
- items = request.get("items", [])
- effective_type = request.get("effectiveType", "immediate")
- effective_time_str = request.get("effectiveTime")
- remark = request.get("remark")
- if not region_id:
- return error_response(400, "请选择区域")
- if not supplier_id:
- return error_response(400, "请选择供应商")
- if not items:
- return error_response(400, "请添加价格项")
- effective_time = None
- if effective_time_str:
- try:
- effective_time = datetime.fromisoformat(effective_time_str.replace("Z", "+00:00"))
- except:
- effective_time = None
- creator = {
- "user_id": current_user.get("user_id"),
- "real_name": current_user.get("real_name")
- }
- price_list = price_list_crud.create_with_items(
- db,
- obj_in={
- "region_id": region_id,
- "supplier_id": supplier_id,
- "effective_time": effective_time,
- "remark": remark
- },
- items=items,
- creator=creator
- )
- # 如果是立即生效,直接发布
- if effective_type == "immediate":
- price_list_crud.publish(
- db,
- price_list.id,
- current_user.get("user_id"),
- effective_type="immediate"
- )
- return success_response({
- "id": price_list.id,
- "message": "创建成功"
- })
- @router.get("/price-lists/{price_list_id}", response_model=dict)
- def get_price_list(
- price_list_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取价格清单详情"""
- from app.crud.price_list import price_list_crud, price_list_item_crud
- from app.crud.user import user_crud
- price_list = price_list_crud.get(db, price_list_id)
- if not price_list:
- return error_response(404, "价格清单不存在")
- items = price_list_item_crud.get_by_price_list(db, price_list_id)
- # 获取关联信息
- region_name = None
- if price_list.region_id:
- region = region_crud.get(db, price_list.region_id)
- region_name = region.name if region else None
- supplier_name = None
- if price_list.supplier_id:
- supplier = user_crud.get(db, price_list.supplier_id)
- supplier_name = supplier.real_name if supplier else None
- publisher_name = None
- if price_list.published_by:
- publisher = user_crud.get(db, price_list.published_by)
- publisher_name = publisher.real_name if publisher else None
- # 订单关联供应商(来自订单价格清单)
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, order.price_list_id) if order.price_list_id else None
- supplier_id_from_price_list = getattr(price_list, "supplier_id", None) if price_list else None
- supplier_name_from_price_list = None
- if supplier_id_from_price_list:
- _sup_u = user_crud.get(db, supplier_id_from_price_list)
- if _sup_u:
- supplier_name_from_price_list = _sup_u.real_name or _sup_u.username
- pl_ids_for_order = [order.price_list_id] if order.price_list_id else []
- pli_map_for_order = _price_list_item_map(db, pl_ids_for_order)
- # 获取商品和单位信息
- item_details = []
- supplier_total_amount = 0.0
- selling_total_amount = 0.0
- for item in items:
- product = product_crud.get(db, item.product_id)
- category_name = None
- if product and product.category_id:
- cat = category_crud.get(db, product.category_id)
- category_name = cat.name if cat else None
- unit_name = None
- if item.unit_id:
- u = unit_crud.get(db, item.unit_id)
- unit_name = u.name if u else None
- qty = float(item.quantity) if item.quantity else 0
- selling_u = float(item.unit_price) if item.unit_price else 0
- selling_amt = float(item.amount) if item.amount else round(qty * selling_u, 2)
- pli = pli_map_for_order.get((order.price_list_id, item.product_id, item.unit_id))
- supplier_u = _supplier_unit_yuan_from_pli(pli)
- if supplier_u is None:
- supplier_u = selling_u
- supplier_amt = round(qty * supplier_u, 2)
- supplier_total_amount += supplier_amt
- selling_total_amount += selling_amt
- item_details.append({
- "id": item.id,
- "productId": item.product_id,
- "productName": product.name if product else None,
- "categoryName": category_name,
- "unitId": item.unit_id,
- "unitName": unit_name,
- "price": float(item.price) if item.price else 0,
- "imageUrl": product.image_url if product else None
- })
- return success_response({
- "id": price_list.id,
- "regionId": price_list.region_id,
- "regionName": region_name,
- "supplierId": price_list.supplier_id,
- "supplierName": supplier_name,
- "version": price_list.version,
- "status": price_list.status,
- "effectiveTime": str(price_list.effective_time) if price_list.effective_time else None,
- "publishedBy": price_list.published_by,
- "publishedByName": publisher_name,
- "publishedTime": str(price_list.published_time) if price_list.published_time else None,
- "remark": price_list.remark,
- "createAt": str(price_list.create_at) if price_list.create_at else None,
- "items": item_details
- })
- @router.put("/price-lists/{price_list_id}", response_model=dict)
- def update_price_list(
- price_list_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """更新价格清单(只有草稿状态可编辑)"""
- from app.crud.price_list import price_list_crud
- from datetime import datetime
- price_list = price_list_crud.get(db, price_list_id)
- if not price_list:
- return error_response(404, "价格清单不存在")
- if price_list.status != "draft":
- return error_response(400, "只有草稿状态的价格清单可以编辑")
- items = request.get("items")
- effective_time_str = request.get("effectiveTime")
- remark = request.get("remark")
- obj_in = {}
- if effective_time_str:
- try:
- obj_in["effective_time"] = datetime.fromisoformat(effective_time_str.replace("Z", "+00:00"))
- except:
- pass
- if remark is not None:
- obj_in["remark"] = remark
- updater = {
- "user_id": current_user.get("user_id"),
- "real_name": current_user.get("real_name")
- }
- price_list_crud.update_with_items(
- db,
- price_list_id,
- obj_in,
- items=items,
- updater=updater
- )
- return success_response({
- "id": price_list_id,
- "message": "更新成功"
- })
- @router.post("/price-lists/{price_list_id}/publish", response_model=dict)
- def publish_price_list(
- price_list_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """发布价格清单"""
- from app.crud.price_list import price_list_crud
- from datetime import datetime
- price_list = price_list_crud.get(db, price_list_id)
- if not price_list:
- return error_response(404, "价格清单不存在")
- if price_list.status != "draft":
- return error_response(400, "只有草稿状态的价格清单可以发布")
- effective_type = request.get("effectiveType", "immediate")
- effective_time_str = request.get("effectiveTime")
- effective_time = None
- if effective_time_str:
- try:
- effective_time = datetime.fromisoformat(effective_time_str.replace("Z", "+00:00"))
- except:
- pass
- price_list_crud.publish(
- db,
- price_list_id,
- current_user.get("user_id"),
- effective_time=effective_time,
- effective_type=effective_type
- )
- # 广播:价格清单发布后,通知该区域客户端刷新价格
- try:
- from app.ws.price_updates import notify_price_list_updated, PriceListUpdatedEvent
- db.refresh(price_list)
- notify_price_list_updated(PriceListUpdatedEvent(
- regionId=str(getattr(price_list, "region_id", "") or ""),
- priceListId=str(getattr(price_list, "id", "") or ""),
- priceVersion=int(getattr(price_list, "version", 0) or 0),
- publishedTime=str(getattr(price_list, "published_time", None) or getattr(price_list, "publish_time", None) or ""),
- ))
- except Exception:
- # 推送失败不影响主流程
- logger.exception(
- "[ws-mini] notify failed in publish_price_list price_list_id=%s",
- price_list_id,
- )
- return success_response({
- "id": price_list_id,
- "message": "发布成功"
- })
- @router.post("/price-lists/{price_list_id}/invalidate", response_model=dict)
- def invalidate_price_list(
- price_list_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """使价格清单失效"""
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, price_list_id)
- if not price_list:
- return error_response(404, "价格清单不存在")
- if price_list.status not in ["active", "pending"]:
- return error_response(400, "只有生效中或待生效的价格清单可以失效")
- price_list_crud.invalidate(db, price_list_id)
- return success_response({
- "id": price_list_id,
- "message": "操作成功"
- })
- @router.delete("/price-lists/{price_list_id}", response_model=dict)
- def delete_price_list(
- price_list_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """删除价格清单(只有草稿状态可删除)"""
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, price_list_id)
- if not price_list:
- return error_response(404, "价格清单不存在")
- if price_list.status != "draft":
- return error_response(400, "只有草稿状态的价格清单可以删除")
- price_list_crud.delete(db, price_list_id)
- return success_response({
- "message": "删除成功"
- })
- @router.get("/price-lists/product-history", response_model=dict)
- def get_product_price_history(
- regionId: str = Query(..., description="区域ID"),
- productId: str = Query(..., description="商品ID"),
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(20, ge=1, le=100, description="每页条数"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """按商品维度查询价格历史"""
- from app.crud.price_list import price_list_crud, price_list_item_crud
- product = product_crud.get(db, productId)
- if not product:
- return error_response(404, "商品不存在")
- items, total = price_list_item_crud.get_product_price_history(
- db,
- region_id=regionId,
- product_id=productId,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- # 获取单位信息
- for item in items:
- if item.get("unit_id"):
- u = unit_crud.get(db, item["unit_id"])
- item["unitName"] = u.name if u else None
- return success_response({
- "productId": productId,
- "productName": product.name,
- "history": items,
- "total": total,
- "page": page,
- "pageSize": pageSize
- })
- @router.get("/price-lists/generate-options", response_model=dict)
- def get_generate_price_list_options(
- regionId: str = Query(..., description="区域ID"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取生成价格清单的选项(该区域已提交、尚未被选中的报价单,报价无审核流程)"""
- from app.crud.quotation import quotation_crud, quotation_item_crud
- from app.crud.user import user_crud
- quotations, total = quotation_crud.get_multi_with_filters(
- db,
- region_id=regionId,
- status="submitted",
- skip=0,
- limit=100
- )
- result = []
- for q in quotations:
- supplier = user_crud.get(db, q.supplier_id)
- items = quotation_item_crud.get_by_quotation(db, q.id)
- item_details = []
- for item in items:
- product = product_crud.get(db, item.product_id)
- unit_name = None
- if item.unit_id:
- u = unit_crud.get(db, item.unit_id)
- unit_name = u.name if u else None
- item_details.append({
- "productId": item.product_id,
- "productName": product.name if product else None,
- "unitId": item.unit_id,
- "unitName": unit_name,
- "price": float(item.price) if item.price else 0
- })
- result.append({
- "quotationId": q.id,
- "quotationNo": f"QT{q.create_at.strftime('%Y%m%d%H%M%S') if q.create_at else ''}",
- "supplierId": q.supplier_id,
- "supplierName": supplier.real_name if supplier else None,
- "totalAmount": float(q.total_amount) if q.total_amount else 0,
- "items": item_details
- })
- return success_response({
- "list": result,
- "total": len(result)
- })
- @router.get("/price-lists/compare", response_model=dict)
- def compare_price_list_versions(
- regionId: str = Query(..., description="区域ID"),
- version1Id: str = Query(..., description="版本1ID"),
- version2Id: str = Query(..., description="版本2ID"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """对比两个价格版本"""
- from app.crud.price_list import price_list_crud, price_list_item_crud
- # 获取两个版本的价格清单
- price_list1 = price_list_crud.get(db, version1Id)
- price_list2 = price_list_crud.get(db, version2Id)
- if not price_list1 or not price_list2:
- return error_response(404, "价格版本不存在")
- if price_list1.region_id != regionId or price_list2.region_id != regionId:
- return error_response(400, "价格版本不属于该区域")
- # 获取两个版本的价格项
- items1 = price_list_item_crud.get_by_price_list(db, version1Id)
- items2 = price_list_item_crud.get_by_price_list(db, version2Id)
- # 构建价格映射
- price_map1 = {item.product_id: item for item in items1}
- price_map2 = {item.product_id: item for item in items2}
- # 获取所有涉及的商品
- all_product_ids = set(price_map1.keys()) | set(price_map2.keys())
- # 构建对比数据
- comparison = []
- for product_id in all_product_ids:
- item1 = price_map1.get(product_id)
- item2 = price_map2.get(product_id)
- product = product_crud.get(db, product_id)
- unit_name = None
- if item1 and item1.unit_id:
- u = unit_crud.get(db, item1.unit_id)
- unit_name = u.name if u else None
- elif item2 and item2.unit_id:
- u = unit_crud.get(db, item2.unit_id)
- unit_name = u.name if u else None
- price1 = float(item1.price) if item1 and item1.price else None
- price2 = float(item2.price) if item2 and item2.price else None
- # 计算差异
- price_diff = None
- price_change = None
- change_rate = None
- if price1 is not None and price2 is not None:
- price_diff = price2 - price1
- if price1 > 0:
- change_rate = round((price_diff / price1) * 100, 2)
- if price_diff > 0:
- price_change = "up"
- elif price_diff < 0:
- price_change = "down"
- else:
- price_change = "same"
- comparison.append({
- "productId": product_id,
- "productName": product.name if product else None,
- "categoryId": product.category_id if product else None,
- "unitId": item1.unit_id if item1 else (item2.unit_id if item2 else None),
- "unitName": unit_name,
- "price1": price1,
- "price2": price2,
- "priceDiff": price_diff,
- "priceChange": price_change,
- "changeRate": change_rate,
- "existIn1": item1 is not None,
- "existIn2": item2 is not None
- })
- # 计算汇总统计
- total_products = len(comparison)
- price_up_count = sum(1 for c in comparison if c["priceChange"] == "up")
- price_down_count = sum(1 for c in comparison if c["priceChange"] == "down")
- price_same_count = sum(1 for c in comparison if c["priceChange"] == "same")
- new_product_count = sum(1 for c in comparison if not c["existIn1"] and c["existIn2"])
- removed_product_count = sum(1 for c in comparison if c["existIn1"] and not c["existIn2"])
- return success_response({
- "version1": {
- "id": price_list1.id,
- "version": price_list1.version,
- "status": price_list1.status,
- "publishedTime": str(price_list1.published_time) if price_list1.published_time else None
- },
- "version2": {
- "id": price_list2.id,
- "version": price_list2.version,
- "status": price_list2.status,
- "publishedTime": str(price_list2.published_time) if price_list2.published_time else None
- },
- "statistics": {
- "totalProducts": total_products,
- "priceUp": price_up_count,
- "priceDown": price_down_count,
- "priceSame": price_same_count,
- "newProducts": new_product_count,
- "removedProducts": removed_product_count
- },
- "comparison": comparison
- })
- @router.get("/price-lists/history", response_model=dict)
- def get_price_list_history(
- regionId: str = Query(..., description="区域ID"),
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=50, description="每页条数"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取价格清单历史版本列表"""
- from app.crud.price_list import price_list_crud
- from app.crud.user import user_crud
- price_lists, total = price_list_crud.get_multi_with_filters(
- db,
- region_id=regionId,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- result = []
- for pl in price_lists:
- supplier = user_crud.get(db, pl.supplier_id)
- region = region_crud.get(db, pl.region_id)
- result.append({
- "id": pl.id,
- "version": pl.version,
- "status": pl.status,
- "statusText": "生效中" if pl.status == "active" else "待生效" if pl.status == "pending" else "已失效" if pl.status == "expired" else "草稿",
- "supplierId": pl.supplier_id,
- "supplierName": supplier.real_name if supplier else None,
- "regionName": region.name if region else None,
- "effectiveTime": str(pl.effective_time) if pl.effective_time else None,
- "publishedTime": str(pl.published_time) if pl.published_time else None,
- "remark": pl.remark,
- "createAt": str(pl.create_at) if pl.create_at else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": pageSize
- })
- # =========================================
- # 订单管理 API (Web端)
- # =========================================
- # 订单状态映射
- ORDER_STATUS_MAP = {
- "pending_payment": "待支付",
- "pending_review_cancel": "待审核取消",
- "pending_dispatch": "待分配配送",
- "delivering": "配送中",
- "pending_confirm": "待确认收货",
- "completed": "已完结",
- "cancelled": "已取消"
- }
- # 订单状态日志展示(含配送/支付维度的结构化 to_status)
- DELIVERY_STATUS_LOG_LABELS = {
- "pending_dispatch": "待分配配送",
- "delivering": "配送中",
- "delivered": "配送完成",
- }
- def _order_log_status_display_map() -> dict:
- m = dict(ORDER_STATUS_MAP)
- m["delivery.assign"] = "分配配送员"
- m["delivery.reassign"] = "更换配送员"
- for k, v in DELIVERY_STATUS_LOG_LABELS.items():
- m[f"ds_{k}"] = f"配送状态:{v}"
- m["pay_pending_payment"] = "支付状态:待支付"
- m["pay_paid"] = "支付状态:已支付"
- m["pay_refunding"] = "支付状态:退款中"
- m["pay_refunded"] = "支付状态:已退款"
- _task_lbl = {
- "pending": "待分配",
- "assigned": "已分配",
- "supplier_stocking": "供应商备货中",
- "supplier_ready": "备货完成(待取货)",
- "picking": "取货中",
- "delivering": "配送中",
- "completed": "已完成",
- "cancelled": "已取消",
- }
- for k, v in _task_lbl.items():
- m[f"dt_{k}"] = f"配送任务:{v}"
- return m
- ORDER_LOG_STATUS_MAP = _order_log_status_display_map()
- def _status_log_timeline_description(entry: dict) -> str:
- """与 web-admin 订单详情「操作记录」中对 reason/note 的展示规则保持一致。"""
- note = (entry.get("note") or "").strip()
- raw_reason = entry.get("reason")
- description = note or ""
- if raw_reason and str(raw_reason).strip().startswith("{"):
- try:
- o = json.loads(raw_reason)
- if isinstance(o, dict) and (
- o.get("toDeliverName") is not None or o.get("fromDeliverName") is not None
- ):
- description = f"配送员:{o.get('fromDeliverName') or '—'} → {o.get('toDeliverName') or '—'}"
- if note:
- description += f";{note}"
- except json.JSONDecodeError:
- if not description:
- description = str(raw_reason)
- elif raw_reason and not description:
- description = str(raw_reason)
- return description
- def _delivery_timeline_type(to_status: str) -> str:
- if to_status == "dt_completed":
- return "success"
- if to_status == "dt_cancelled":
- return "danger"
- return "primary"
- def _log_entry_in_delivery_timeline(entry: dict) -> bool:
- ts = entry.get("status") or ""
- if ts in ("delivery.assign", "delivery.reassign"):
- return True
- if ts.startswith("dt_") or ts.startswith("ds_"):
- return True
- return False
- def _build_order_delivery_timeline(dtask, log_details: List[dict], order) -> List[dict]:
- """
- 配送进度以订单状态日志为权威来源(配送任务 dt_*、配送状态 ds_*、分配配送员等),
- 与「操作记录」同源,避免两套拼接逻辑长期漂移。
- """
- events: List[dict] = []
- if dtask is not None and dtask.create_at:
- dr = (getattr(dtask, "dispatch_remark", None) or "").strip() or None
- events.append({
- "time": str(dtask.create_at),
- "title": "配送任务已创建",
- "description": dr or "",
- "type": "info",
- "hollow": False,
- })
- timeline_statuses: set = set()
- for entry in log_details:
- if not _log_entry_in_delivery_timeline(entry):
- continue
- ts_key = entry.get("status") or ""
- timeline_statuses.add(ts_key)
- t = entry.get("createAt")
- if not t:
- continue
- from_t = (entry.get("fromStatusText") or "").strip()
- to_t = (entry.get("statusText") or "").strip() or ts_key or "-"
- title = f"{from_t} → {to_t}" if from_t else to_t
- events.append({
- "time": t,
- "title": title,
- "description": _status_log_timeline_description(entry),
- "type": _delivery_timeline_type(ts_key),
- "hollow": False,
- })
- if dtask is not None:
- if dtask.pickup_time and "dt_picking" not in timeline_statuses:
- pr = (dtask.pickup_remark or "").strip() or ""
- events.append({
- "time": str(dtask.pickup_time),
- "title": "配送任务:取货中",
- "description": pr,
- "type": "primary",
- "hollow": False,
- })
- if "dt_completed" not in timeline_statuses and (
- dtask.status == "completed"
- or (getattr(dtask, "deliverer_status", None) == "delivered")
- ):
- t_end = dtask.delivery_time or getattr(order, "delivered_time", None)
- if t_end:
- dr = (dtask.delivery_remark or "").strip() or ""
- events.append({
- "time": str(t_end),
- "title": "配送任务:已完成",
- "description": dr,
- "type": "success",
- "hollow": False,
- })
- events = [e for e in events if e.get("time")]
- events.sort(key=lambda x: str(x.get("time") or ""))
- return events
- @router.get("/orders", response_model=dict)
- def list_orders(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- status: Optional[str] = Query(None, description="订单状态"),
- orderStatus: Optional[str] = Query(None, description="订单主状态"),
- deliveryStatus: Optional[str] = Query(None, description="配送状态"),
- payStatus: Optional[str] = Query(None, description="支付状态"),
- paymentMethod: Optional[str] = Query(None, description="支付方式: online/offline"),
- clientId: Optional[str] = Query(None, description="客户ID"),
- orderNo: Optional[str] = Query(None, description="订单号"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取订单列表"""
- from app.crud.order import order_crud, order_item_crud
- from sqlalchemy import func
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None:
- if regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- if not regionId and allowed_region_ids:
- regionId = sorted(allowed_region_ids)[0]
- orders, total = order_crud.get_multi_with_filters(
- db,
- region_id=regionId,
- status=status,
- order_status=orderStatus,
- delivery_status=deliveryStatus,
- pay_status=payStatus,
- payment_method=paymentMethod,
- client_id=clientId,
- order_no=orderNo,
- start_date=startDate,
- end_date=endDate,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- # 列表页:按订单行汇总供货总金额 / 售出总金额(与订单详情口径一致)
- order_ids_page = [o.id for o in orders]
- oid_to_order = {o.id: o for o in orders}
- items_by_oid: dict = {}
- if order_ids_page:
- for ln in (
- db.query(BizOrderItem)
- .filter(BizOrderItem.order_id.in_(order_ids_page), BizOrderItem.is_del == 0)
- .all()
- ):
- items_by_oid.setdefault(ln.order_id, []).append(ln)
- pl_ids_page = list({o.price_list_id for o in orders if o.price_list_id})
- pli_map_page = _price_list_item_map(db, pl_ids_page) if pl_ids_page else {}
- order_supplier_selling_totals: dict = {}
- for oid, lines in items_by_oid.items():
- o = oid_to_order.get(oid)
- pl_id = o.price_list_id if o else None
- sup_acc = 0.0
- sel_acc = 0.0
- for item in lines:
- qty = float(item.quantity or 0)
- selling_u = float(item.unit_price or 0)
- selling_amt = (
- float(item.amount) if item.amount is not None else round(qty * selling_u, 2)
- )
- pli = pli_map_page.get((pl_id, item.product_id, item.unit_id)) if pl_id else None
- supplier_u = _supplier_unit_yuan_from_pli(pli)
- if supplier_u is None:
- supplier_u = selling_u
- sup_acc += round(qty * supplier_u, 2)
- sel_acc += selling_amt
- order_supplier_selling_totals[oid] = (round(sup_acc, 2), round(sel_acc, 2))
- region_map = {}
- region_delivery_minutes_map = {}
- client_map = {}
- item_count_map: dict[str, int] = {}
- if order_ids_page:
- # 避免 N+1:批量聚合订单项数量
- for row in (
- db.query(BizOrderItem.order_id, func.count(BizOrderItem.id))
- .filter(BizOrderItem.is_del == 0, BizOrderItem.order_id.in_(order_ids_page))
- .group_by(BizOrderItem.order_id)
- .all()
- ):
- item_count_map[str(row[0])] = int(row[1] or 0)
- result = []
- for order in orders:
- order_status = getattr(order, "order_status", None) or "pending_confirm"
- delivery_status = getattr(order, "delivery_status", None) or "pending_dispatch"
- pay_status = getattr(order, "pay_status", None) or "pending_payment"
- # 获取区域名称、预计配送时长
- if order.region_id and order.region_id not in region_map:
- region = region_crud.get(db, order.region_id)
- region_map[order.region_id] = region.name if region else None
- region_delivery_minutes_map[order.region_id] = (
- getattr(region, "expected_delivery_minutes", None) if region else None
- )
- # 获取客户名称
- if order.client_id and order.client_id not in client_map:
- client = user_crud.get(db, order.client_id)
- client_map[order.client_id] = client.real_name if client else None
- item_count = item_count_map.get(order.id, 0)
- _sup_total, _sel_total = order_supplier_selling_totals.get(order.id, (0.0, 0.0))
- result.append({
- "id": order.id,
- "orderNo": order.order_no,
- "clientId": order.client_id,
- "clientName": client_map.get(order.client_id),
- "regionId": order.region_id,
- "regionName": region_map.get(order.region_id),
- "regionExpectedDeliveryMinutes": region_delivery_minutes_map.get(order.region_id),
- "totalAmount": float(order.total_amount) if order.total_amount else 0,
- "supplierTotalAmount": _sup_total,
- "sellingTotalAmount": _sel_total,
- "paidAmount": float(order.paid_amount) if order.paid_amount else 0,
- "receivableAmount": float(order.receivable_amount) if order.receivable_amount else 0,
- "status": order.status,
- "orderStatus": order_status,
- "deliveryStatus": delivery_status,
- "payStatus": pay_status,
- "cancelStatus": getattr(order, "cancel_status", None),
- "paymentMethod": order_crud.normalize_payment_method(order.payment_method),
- "paymentStatus": "paid" if order.paid_amount and order.paid_amount > 0 else "unpaid",
- "itemCount": item_count,
- "createAt": str(order.create_at) if order.create_at else None,
- "paymentTime": str(order.payment_time) if order.payment_time else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/orders/{order_id}", response_model=dict)
- def get_order(
- order_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取订单详情"""
- from app.crud.order import order_crud, order_item_crud, order_status_log_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- # 获取订单项
- items = order_item_crud.get_by_order(db, order_id)
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, order.price_list_id) if order.price_list_id else None
- supplier_id_from_price_list = getattr(price_list, "supplier_id", None) if price_list else None
- supplier_name_from_price_list = None
- if supplier_id_from_price_list:
- _sup_u = user_crud.get(db, supplier_id_from_price_list)
- if _sup_u:
- supplier_name_from_price_list = _sup_u.real_name or _sup_u.username
- pl_ids_for_order = [order.price_list_id] if order.price_list_id else []
- pli_map_for_order = _price_list_item_map(db, pl_ids_for_order)
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, order.price_list_id) if order.price_list_id else None
- supplier_id_from_price_list = getattr(price_list, "supplier_id", None) if price_list else None
- supplier_name_from_price_list = None
- if supplier_id_from_price_list:
- _sup_u = user_crud.get(db, supplier_id_from_price_list)
- if _sup_u:
- supplier_name_from_price_list = _sup_u.real_name or _sup_u.username
- pl_ids_for_order = [order.price_list_id] if order.price_list_id else []
- pli_map_for_order = _price_list_item_map(db, pl_ids_for_order)
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, order.price_list_id) if order.price_list_id else None
- supplier_id_from_price_list = getattr(price_list, "supplier_id", None) if price_list else None
- supplier_name_from_price_list = None
- if supplier_id_from_price_list:
- _sup_u = user_crud.get(db, supplier_id_from_price_list)
- if _sup_u:
- supplier_name_from_price_list = _sup_u.real_name or _sup_u.username
- pl_ids_for_order = [order.price_list_id] if order.price_list_id else []
- pli_map_for_order = _price_list_item_map(db, pl_ids_for_order)
- # 获取状态日志
- status_logs = order_status_log_crud.get_by_order(db, order_id)
- # 尝试锁定订单(防止并发操作)
- lock_info = None
- operator_id = current_user.get("user_id")
- try:
- order_crud.lock_order(db, order_id, operator_id)
- except ValueError as e:
- # 已被其他用户锁定,返回锁定信息但不阻止查看
- lock_info = {
- "isLocked": True,
- "lockedBy": order.lock_operator_id,
- "lockedTime": str(order.lock_time) if order.lock_time else None,
- "message": str(e)
- }
- else:
- lock_info = {"isLocked": False}
- # 获取关联信息
- region_name = None
- region_expected_delivery_minutes = None
- if order.region_id:
- region = region_crud.get(db, order.region_id)
- region_name = region.name if region else None
- region_expected_delivery_minutes = (
- getattr(region, "expected_delivery_minutes", None) if region else None
- )
- client_name = None
- if order.client_id:
- client = user_crud.get(db, order.client_id)
- client_name = client.real_name if client else None
- # 配送任务明细上的已送达量(订单行 delivered_quantity 可能未同步,展示时与任务明细取大)
- from app.crud.delivery import delivery_task_item_crud as _merge_task_delivered
- _line_delivered_from_task = _merge_task_delivered.get_delivered_quantity_by_order_item(db, order_id)
- # 获取商品和单位信息
- item_details = []
- supplier_total_amount = 0.0
- selling_total_amount = 0.0
- for item in items:
- product = product_crud.get(db, item.product_id)
- unit_name = None
- if item.unit_id:
- u = unit_crud.get(db, item.unit_id)
- unit_name = u.name if u else None
- category_id = None
- category_path = ""
- image_url = None
- measure_value = None
- if product:
- category_id = getattr(product, "category_id", None)
- if category_id:
- category_path = _category_path_display(db, str(category_id))
- image_url = getattr(product, "image_url", None)
- measure_value = _numeric_to_float_optional(getattr(product, "measure_value", None))
- _oi_dq = float(item.delivered_quantity) if item.delivered_quantity else 0.0
- _ti_dq = float(_line_delivered_from_task.get(item.id, 0.0))
- _merged_dq = max(_oi_dq, _ti_dq)
- qty = float(item.quantity) if item.quantity else 0
- selling_u = float(item.unit_price) if item.unit_price else 0
- selling_amt = float(item.amount) if item.amount else round(qty * selling_u, 2)
- pli = pli_map_for_order.get((order.price_list_id, item.product_id, item.unit_id))
- supplier_u = _supplier_unit_yuan_from_pli(pli)
- if supplier_u is None:
- supplier_u = selling_u
- supplier_amt = round(qty * supplier_u, 2)
- supplier_total_amount += supplier_amt
- selling_total_amount += selling_amt
- item_details.append({
- "id": item.id,
- "productId": item.product_id,
- "productName": product.name if product else None,
- "imageUrl": image_url,
- "categoryId": str(category_id) if category_id else None,
- "categoryName": category_path or None,
- "measureValue": measure_value,
- "unitId": item.unit_id,
- "unitName": unit_name,
- "supplierId": supplier_id_from_price_list,
- "supplierName": supplier_name_from_price_list,
- "quantity": qty,
- "unitPrice": selling_u,
- "amount": selling_amt,
- "sellingUnitPrice": selling_u,
- "sellingAmount": selling_amt,
- "supplierUnitPrice": supplier_u,
- "supplierAmount": supplier_amt,
- "deliveredQuantity": _merged_dq,
- "status": item.status
- })
- order_status = getattr(order, "order_status", None) or "pending_confirm"
- delivery_status = getattr(order, "delivery_status", None) or "pending_dispatch"
- pay_status = getattr(order, "pay_status", None) or "pending_payment"
- # 处理状态日志(含配送任务、配送员分配等结构化记录)
- log_details = []
- for log in status_logs:
- log_details.append({
- "fromStatus": log.from_status,
- "fromStatusText": ORDER_LOG_STATUS_MAP.get(
- log.from_status, log.from_status or ""
- ),
- "status": log.to_status,
- "statusText": ORDER_LOG_STATUS_MAP.get(log.to_status, log.to_status),
- # BizOrderStatusLog 无 operator_name 字段,优先使用 create_name
- "operatorName": getattr(log, "create_name", None) or getattr(log, "operator_id", None),
- "operatorRole": log.operator_role,
- "reason": log.reason,
- "note": log.note,
- "createAt": str(log.create_at) if log.create_at else None
- })
- deliver_id_out = None
- deliver_name = None
- deliver_phone = None
- expected_time = None
- delivery_dispatch_remark = None
- delivery_timeline = []
- supplier_id_for_pickup = None
- supplier_name = None
- supplier_phone = None
- supplier_pickup = {"fullAddress": None, "contactPerson": None, "contactPhone": None}
- delivery_task_no = None
- delivery_task_status = None
- delivery_task_deliverer_status = None
- delivery_task_supplier_prepare_status = None
- delivery_pickup_time = None
- delivery_pickup_remark = None
- delivery_delivery_time = None
- delivery_delivery_remark = None
- delivery_prepare_images = []
- delivery_pickup_images = []
- delivery_deliver_images = []
- from app.crud.delivery import delivery_task_crud as _order_delivery_task_crud
- dtask = _order_delivery_task_crud.get_by_order(db, order_id)
- if dtask:
- deliver_id_out = dtask.deliver_id
- delivery_task_no = dtask.task_no
- delivery_task_status = dtask.status
- delivery_task_deliverer_status = getattr(dtask, "deliverer_status", None)
- delivery_task_supplier_prepare_status = getattr(dtask, "supplier_prepare_status", None)
- delivery_pickup_time = str(dtask.pickup_time) if dtask.pickup_time else None
- delivery_pickup_remark = dtask.pickup_remark
- delivery_delivery_time = str(dtask.delivery_time) if dtask.delivery_time else None
- delivery_delivery_remark = dtask.delivery_remark
- if dtask.deliver_id:
- du = user_crud.get(db, dtask.deliver_id)
- if du:
- deliver_name = du.real_name
- deliver_phone = du.phone
- expected_time = str(dtask.effective_time) if dtask.effective_time else None
- delivery_dispatch_remark = getattr(dtask, "dispatch_remark", None) or None
- supplier_id_for_pickup = dtask.supplier_id
- delivery_timeline = _build_order_delivery_timeline(dtask, log_details, order)
- from app.crud.attachment import attachment_crud as _attachment_crud
- delivery_prepare_images = [
- a.file_url for a in _attachment_crud.get_by_business(db, "delivery_prepare", dtask.id) if a.file_url
- ]
- delivery_pickup_images = [
- a.file_url for a in _attachment_crud.get_by_business(db, "delivery_pickup", dtask.id) if a.file_url
- ]
- delivery_deliver_images = [
- a.file_url for a in _attachment_crud.get_by_business(db, "delivery_deliver", dtask.id) if a.file_url
- ]
- # 兼容旧数据:历史仅写在任务字段中
- if not delivery_pickup_images and getattr(dtask, "pickup_image_url", None):
- delivery_pickup_images = [
- s.strip() for s in str(dtask.pickup_image_url).split(",") if s and str(s).strip()
- ]
- if not delivery_deliver_images and getattr(dtask, "delivery_image_url", None):
- delivery_deliver_images = [
- s.strip() for s in str(dtask.delivery_image_url).split(",") if s and str(s).strip()
- ]
- else:
- from app.crud.price_list import price_list_crud
- _pl = price_list_crud.get(db, order.price_list_id)
- if _pl and getattr(_pl, "supplier_id", None):
- supplier_id_for_pickup = _pl.supplier_id
- delivery_timeline = _build_order_delivery_timeline(None, log_details, order)
- if supplier_id_for_pickup:
- _su = user_crud.get(db, supplier_id_for_pickup)
- if _su:
- supplier_name = _su.real_name
- supplier_phone = _su.phone
- supplier_pickup = _supplier_pickup_address_block(db, supplier_id_for_pickup)
- return success_response({
- "id": order.id,
- "orderNo": order.order_no,
- "regionId": order.region_id,
- "regionName": region_name,
- "regionExpectedDeliveryMinutes": region_expected_delivery_minutes,
- "clientId": order.client_id,
- "clientName": client_name,
- "priceListId": order.price_list_id,
- "priceVersion": order.price_version,
- "status": order.status,
- "orderStatus": order_status,
- "deliveryStatus": delivery_status,
- "payStatus": pay_status,
- "paymentMethod": order_crud.normalize_payment_method(order.payment_method),
- "totalAmount": float(order.total_amount) if order.total_amount else 0,
- "sellingTotalAmount": round(selling_total_amount, 2),
- "supplierTotalAmount": round(supplier_total_amount, 2),
- "paidAmount": float(order.paid_amount) if order.paid_amount else 0,
- "receivableAmount": float(order.receivable_amount) if order.receivable_amount else 0,
- "refundAmount": float(order.refund_amount) if order.refund_amount else 0,
- "contactPerson": order.contact_person,
- "contactPhone": order.contact_phone,
- "deliveryAddress": order.delivery_address,
- "remark": order.remark,
- "cancelReason": order.cancel_reason,
- "cancelReviewNote": order.cancel_review_note,
- "createAt": str(order.create_at) if order.create_at else None,
- "paymentTime": str(order.payment_time) if order.payment_time else None,
- "completedTime": str(order.completed_time) if order.completed_time else None,
- "deliverId": deliver_id_out,
- "deliverName": deliver_name,
- "deliverPhone": deliver_phone,
- "expectedTime": expected_time,
- "deliveryDispatchRemark": delivery_dispatch_remark,
- "deliveryTimeline": delivery_timeline,
- "supplierId": supplier_id_for_pickup,
- "supplierName": supplier_name,
- "supplierPhone": supplier_phone,
- "supplierPickup": {
- "address": supplier_pickup.get("fullAddress"),
- "contactPerson": supplier_pickup.get("contactPerson"),
- "contactPhone": supplier_pickup.get("contactPhone"),
- },
- "deliveryTaskNo": delivery_task_no,
- "deliveryTaskStatus": delivery_task_status,
- "deliveryTaskDelivererStatus": delivery_task_deliverer_status,
- "deliveryTaskSupplierPrepareStatus": delivery_task_supplier_prepare_status,
- "deliveryPickupTime": delivery_pickup_time,
- "deliveryPickupRemark": delivery_pickup_remark,
- "deliveryDeliveryTime": delivery_delivery_time,
- "deliveryDeliveryRemark": delivery_delivery_remark,
- "deliveryPrepareImages": delivery_prepare_images,
- "deliveryPickupImages": delivery_pickup_images,
- "deliveryDeliverImages": delivery_deliver_images,
- "items": item_details,
- "statusLogs": log_details,
- "lockInfo": lock_info
- })
- @router.post("/orders/{order_id}/unlock", response_model=dict)
- def unlock_order(
- order_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """手动解锁订单"""
- from app.crud.order import order_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- operator_id = current_user.get("user_id")
- try:
- order_crud.unlock_order(db, order_id, operator_id)
- return success_response({"message": "解锁成功"})
- except ValueError as e:
- return error_response(400, str(e))
- @router.post("/orders/{order_id}/review-cancel", response_model=dict)
- def review_order_cancel(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """审核取消订单"""
- from app.crud.order import order_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- if getattr(order, "cancel_status", "none") != "pending_review":
- return error_response(400, "只有待审核取消的订单才能审核")
- approved = request.get("approved", False)
- review_note = request.get("reviewNote")
- reviewer_id = current_user.get("user_id")
- order = order_crud.review_cancel(
- db,
- order_id,
- approved=approved,
- reviewer_id=reviewer_id,
- review_note=review_note
- )
- return success_response({
- "id": order.id,
- "status": order.status,
- "orderStatus": getattr(order, "order_status", None),
- "deliveryStatus": getattr(order, "delivery_status", None),
- "payStatus": getattr(order, "pay_status", None),
- "cancelStatus": getattr(order, "cancel_status", None),
- "message": "审核完成"
- })
- @router.get("/orders/pending-cancel", response_model=dict)
- def list_pending_cancel_orders(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取待审核取消的订单列表"""
- from app.crud.order import order_crud, order_item_crud
- orders, total = order_crud.get_multi_with_filters(
- db,
- cancel_status="pending_review",
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- region_map = {}
- client_map = {}
- result = []
- for order in orders:
- if order.region_id and order.region_id not in region_map:
- region = region_crud.get(db, order.region_id)
- region_map[order.region_id] = region.name if region else None
- if order.client_id and order.client_id not in client_map:
- client = user_crud.get(db, order.client_id)
- client_map[order.client_id] = client.real_name if client else None
- item_count = order_item_crud.get_count_by_order(db, order.id)
- result.append({
- "id": order.id,
- "orderNo": order.order_no,
- "clientId": order.client_id,
- "clientName": client_map.get(order.client_id),
- "regionId": order.region_id,
- "regionName": region_map.get(order.region_id),
- "totalAmount": float(order.total_amount) if order.total_amount else 0,
- "paidAmount": float(order.paid_amount) if order.paid_amount else 0,
- "cancelReason": order.cancel_reason,
- "itemCount": item_count,
- "createAt": str(order.create_at) if order.create_at else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- # =========================================
- # 订单锁定管理 API
- # =========================================
- @router.post("/orders/{order_id}/lock", response_model=dict)
- def lock_order(
- order_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """锁定订单"""
- from app.crud.order import order_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- try:
- order = order_crud.lock_order(
- db,
- order_id=order_id,
- operator_id=current_user.get("user_id")
- )
- return success_response({
- "id": order.id,
- "lockOperatorId": order.lock_operator_id,
- "lockTime": str(order.lock_time) if order.lock_time else None,
- "message": "锁定成功"
- })
- except ValueError as e:
- return error_response(400, str(e))
- @router.post("/orders/{order_id}/unlock", response_model=dict)
- def unlock_order(
- order_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """解锁订单"""
- from app.crud.order import order_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- try:
- order = order_crud.unlock_order(
- db,
- order_id=order_id,
- operator_id=current_user.get("user_id")
- )
- return success_response({
- "id": order.id,
- "message": "解锁成功"
- })
- except ValueError as e:
- return error_response(400, str(e))
- # =========================================
- # 订单调整管理 API
- # =========================================
- @router.post("/orders/{order_id}/adjust", response_model=dict)
- def adjust_order(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """订单调整(增量/减量/删除/替代)"""
- import json
- from app.crud.order import order_crud, order_item_crud, order_adjust_crud
- from decimal import Decimal
- from app.models.models import BizDeliveryTask, BizDeliveryTaskItem
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- # 检查订单是否被其他操作员锁定
- operator_id = current_user.get("user_id")
- if order.lock_operator_id and order.lock_operator_id != operator_id:
- lock_time = order.lock_time.strftime("%Y-%m-%d %H:%M:%S") if order.lock_time else "未知"
- return error_response(400, f"订单已被其他操作员于 {lock_time} 锁定,请先解锁后再操作")
- # 仅线下支付订单允许运营调整
- if getattr(order, "payment_method", None) != "offline":
- return error_response(400, "仅线下支付订单支持运营调整")
- # 新状态模型:仅未完成/未取消的线下单支持调整
- if getattr(order, "order_status", None) not in ["pending_payment", "pending_confirm"]:
- return error_response(400, "当前状态不支持调整")
- adjustments = request.get("adjustments", [])
- address_update = request.get("addressUpdate") or {}
- has_address_update = isinstance(address_update, dict) and len(address_update.keys()) > 0
- if not adjustments and not has_address_update:
- return error_response(400, "请提供调整项或地址调整信息")
- operator_name = current_user.get("real_name")
- adjust_results = []
- for adj in adjustments:
- order_item_id = adj.get("orderItemId")
- adjust_type = adj.get("adjustType") # increase/reduce/delete/substitute
- new_quantity = adj.get("newQuantity")
- new_product_id = adj.get("newProductId")
- reason = adj.get("reason")
- if not order_item_id or not adjust_type:
- continue
- if not reason:
- return error_response(400, "调整原因不能为空")
- # 获取订单项
- item = order_item_crud.get(db, order_item_id)
- if not item or item.order_id != order_id:
- continue
- before_quantity = item.quantity
- before_amount = item.amount
- try:
- if adjust_type == "increase":
- # 增量
- if new_quantity is None or new_quantity < 0:
- return error_response(400, "增量后数量不能小于0")
- if new_quantity < float(item.quantity):
- return error_response(400, "增量后数量不能小于原数量")
- new_amount = Decimal(str(new_quantity)) * item.unit_price
- order_item_crud.update_item_adjustment(
- db, order_item_id,
- new_quantity=Decimal(str(new_quantity)),
- new_amount=new_amount,
- new_status="increased",
- note=reason,
- updater_id=operator_id,
- updater_name=operator_name
- )
- snapshot_items = order_item_crud.get_by_order(db, order_id)
- snapshot = [{
- "orderItemId": it.id,
- "productId": it.product_id,
- "quantity": float(it.quantity) if it.quantity else 0,
- "status": it.status,
- } for it in snapshot_items if it.is_del == 0]
- order_adjust_crud.create_adjust_record(
- db,
- order_id=order_id,
- order_item_id=order_item_id,
- adjust_type="increase",
- operator_id=operator_id,
- operator_name=operator_name,
- before_quantity=before_quantity,
- after_quantity=Decimal(str(new_quantity)),
- before_amount=before_amount,
- after_amount=new_amount,
- difference_amount=new_amount - before_amount,
- reason=reason,
- snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
- )
- adjust_results.append({
- "orderItemId": order_item_id,
- "adjustType": "increase",
- "success": True
- })
- elif adjust_type == "reduce":
- # 减量
- if new_quantity is None or new_quantity < 0:
- return error_response(400, "减量后数量不能小于0")
- if new_quantity > float(item.quantity):
- return error_response(400, "减量后数量不能大于原数量")
- new_amount = Decimal(str(new_quantity)) * item.unit_price
- order_item_crud.mark_as_reduced(
- db, order_item_id,
- new_quantity=Decimal(str(new_quantity)),
- new_amount=new_amount,
- note=reason
- )
- snapshot_items = order_item_crud.get_by_order(db, order_id)
- snapshot = [{
- "orderItemId": it.id,
- "productId": it.product_id,
- "quantity": float(it.quantity) if it.quantity else 0,
- "status": it.status,
- } for it in snapshot_items if it.is_del == 0]
- # 创建调整记录
- order_adjust_crud.create_adjust_record(
- db,
- order_id=order_id,
- order_item_id=order_item_id,
- adjust_type="reduce",
- operator_id=operator_id,
- operator_name=operator_name,
- before_quantity=before_quantity,
- after_quantity=Decimal(str(new_quantity)),
- before_amount=before_amount,
- after_amount=new_amount,
- difference_amount=new_amount - before_amount,
- reason=reason,
- snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
- )
- adjust_results.append({
- "orderItemId": order_item_id,
- "adjustType": "reduce",
- "success": True
- })
- elif adjust_type == "delete":
- # 删除
- order_item_crud.mark_as_deleted(
- db, order_item_id,
- note=reason
- )
- snapshot_items = order_item_crud.get_by_order(db, order_id)
- snapshot = [{
- "orderItemId": it.id,
- "productId": it.product_id,
- "quantity": float(it.quantity) if it.quantity else 0,
- "status": it.status,
- } for it in snapshot_items if it.is_del == 0]
- # 创建调整记录
- order_adjust_crud.create_adjust_record(
- db,
- order_id=order_id,
- order_item_id=order_item_id,
- adjust_type="delete",
- operator_id=operator_id,
- operator_name=operator_name,
- before_quantity=before_quantity,
- after_quantity=Decimal("0"),
- before_amount=before_amount,
- after_amount=Decimal("0"),
- difference_amount=-before_amount,
- reason=reason,
- snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
- )
- adjust_results.append({
- "orderItemId": order_item_id,
- "adjustType": "delete",
- "success": True
- })
- elif adjust_type == "substitute":
- # 替代
- if not new_product_id or not new_quantity:
- return error_response(400, "替代商品ID和数量不能为空")
- # 获取新商品信息
- new_product = product_crud.get(db, new_product_id)
- if not new_product:
- return error_response(404, "替代商品不存在")
- # 需要获取新商品的价格
- from app.crud.price_list import price_list_item_crud
- price_item = price_list_item_crud.get_by_product_and_price_list(
- db, new_product_id, order.price_list_id
- )
- if not price_item:
- return error_response(400, "替代商品不在价格清单中")
- new_unit_price = price_item.price
- new_amount = Decimal(str(new_quantity)) * new_unit_price
- order_item_crud.update_item_adjustment(
- db, order_item_id,
- new_quantity=Decimal(str(new_quantity)),
- new_amount=new_amount,
- new_status="substituted",
- note=f"{reason},替代商品: {new_product.name}",
- updater_id=operator_id,
- updater_name=operator_name
- )
- snapshot_items = order_item_crud.get_by_order(db, order_id)
- snapshot = [{
- "orderItemId": it.id,
- "productId": it.product_id,
- "quantity": float(it.quantity) if it.quantity else 0,
- "status": it.status,
- } for it in snapshot_items if it.is_del == 0]
- # 创建调整记录
- order_adjust_crud.create_adjust_record(
- db,
- order_id=order_id,
- order_item_id=order_item_id,
- adjust_type="substitute",
- operator_id=operator_id,
- operator_name=operator_name,
- before_quantity=before_quantity,
- after_quantity=Decimal(str(new_quantity)),
- before_amount=before_amount,
- after_amount=new_amount,
- difference_amount=new_amount - before_amount,
- reason=reason,
- substitute_product_id=new_product_id,
- snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
- )
- adjust_results.append({
- "orderItemId": order_item_id,
- "adjustType": "substitute",
- "newProductId": new_product_id,
- "success": True
- })
- except Exception as e:
- adjust_results.append({
- "orderItemId": order_item_id,
- "adjustType": adjust_type,
- "success": False,
- "error": str(e)
- })
- if has_address_update:
- old_delivery_address = order.delivery_address or ""
- old_contact_person = order.contact_person or ""
- old_contact_phone = order.contact_phone or ""
- new_delivery_address = str(address_update.get("deliveryAddress") or old_delivery_address).strip()
- new_contact_person = str(address_update.get("contactPerson") or old_contact_person).strip()
- new_contact_phone = str(address_update.get("contactPhone") or old_contact_phone).strip()
- address_reason = str(address_update.get("reason") or "").strip()
- address_changed = (
- new_delivery_address != old_delivery_address
- or new_contact_person != old_contact_person
- or new_contact_phone != old_contact_phone
- )
- if address_changed:
- order.delivery_address = new_delivery_address
- order.contact_person = new_contact_person
- order.contact_phone = new_contact_phone
- active_task = db.query(BizDeliveryTask).filter(
- BizDeliveryTask.order_id == order_id,
- BizDeliveryTask.is_del == 0
- ).first()
- if active_task:
- task_items = db.query(BizDeliveryTaskItem).filter(
- BizDeliveryTaskItem.task_id == active_task.id,
- BizDeliveryTaskItem.is_del == 0
- ).all()
- for task_item in task_items:
- task_item.delivery_address = new_delivery_address
- task_item.contact_person = new_contact_person
- task_item.contact_phone = new_contact_phone
- task_item.update_at = datetime.now()
- snapshot_items = order_item_crud.get_by_order(db, order_id)
- snapshot = [{
- "orderItemId": it.id,
- "productId": it.product_id,
- "quantity": float(it.quantity) if it.quantity else 0,
- "status": it.status,
- } for it in snapshot_items if it.is_del == 0]
- reason_parts = []
- reason_parts.append(f"地址:{old_delivery_address or '-'} -> {new_delivery_address or '-'}")
- reason_parts.append(f"联系人:{old_contact_person or '-'} -> {new_contact_person or '-'}")
- reason_parts.append(f"电话:{old_contact_phone or '-'} -> {new_contact_phone or '-'}")
- if address_reason:
- reason_parts.append(f"备注:{address_reason}")
- address_log_reason = ";".join(reason_parts)
- order_adjust_crud.create_adjust_record(
- db,
- order_id=order_id,
- order_item_id=None,
- adjust_type="address",
- operator_id=operator_id,
- operator_name=operator_name,
- before_quantity=Decimal("0"),
- after_quantity=Decimal("0"),
- before_amount=Decimal("0"),
- after_amount=Decimal("0"),
- difference_amount=Decimal("0"),
- reason=address_log_reason,
- snapshot_items_json=json.dumps(snapshot, ensure_ascii=False)
- )
- adjust_results.append({
- "adjustType": "address",
- "success": True
- })
- # 重新计算订单应收金额
- all_items = order_item_crud.get_by_order(db, order_id)
- new_total = sum(
- float(item.amount) for item in all_items
- if item.status != "deleted" and item.is_del == 0
- )
- new_receivable = Decimal(str(new_total))
- order.total_amount = new_receivable
- order.receivable_amount = new_receivable
- order.update_at = datetime.now()
- db.commit()
- db.refresh(order)
- return success_response({
- "id": order.id,
- "totalAmount": float(order.total_amount),
- "receivableAmount": float(order.receivable_amount),
- "adjustments": adjust_results,
- "message": "调整成功"
- })
- @router.get("/orders/{order_id}/adjusts", response_model=dict)
- def list_order_adjusts(
- order_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取订单调整记录"""
- import json
- from app.crud.order import order_crud, order_adjust_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- adjusts = order_adjust_crud.get_by_order(db, order_id)
- result = []
- for adj in adjusts:
- result.append({
- "id": adj.id,
- "orderId": adj.order_id,
- "orderItemId": adj.order_item_id,
- "adjustType": adj.adjust_type,
- "beforeQuantity": float(adj.before_quantity) if adj.before_quantity else 0,
- "afterQuantity": float(adj.after_quantity) if adj.after_quantity else 0,
- "beforeAmount": float(adj.before_amount) if adj.before_amount else 0,
- "afterAmount": float(adj.after_amount) if adj.after_amount else 0,
- "differenceAmount": float(adj.difference_amount) if adj.difference_amount else 0,
- "reason": adj.reason,
- "snapshotItems": json.loads(adj.snapshot_items_json) if getattr(adj, "snapshot_items_json", None) else [],
- "operatorName": adj.operator_name,
- "createAt": str(adj.create_at) if adj.create_at else None
- })
- return success_response({
- "list": result,
- "total": len(result)
- })
- @router.post("/orders/{order_id}/force-complete", response_model=dict)
- def force_complete_order(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """运营强制完结订单"""
- from app.crud.order import order_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- reason = request.get("reason", "运营强制完结")
- try:
- order = order_crud.force_complete(
- db,
- order_id=order_id,
- operator_id=current_user.get("user_id"),
- operator_role="admin",
- reason=reason
- )
- return success_response({
- "id": order.id,
- "status": order.status,
- "orderStatus": getattr(order, "order_status", None) or "pending_confirm",
- "deliveryStatus": getattr(order, "delivery_status", None) or "pending_dispatch",
- "payStatus": getattr(order, "pay_status", None) or "pending_payment",
- "completedTime": str(order.completed_time) if order.completed_time else None,
- "message": "强制完结成功"
- })
- except ValueError as e:
- return error_response(400, str(e))
- @router.post("/orders/{order_id}/partial-delivered", response_model=dict)
- def mark_partial_delivered(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """已废弃:旧的部分送达流程不再支持。"""
- return error_response(410, "该接口已下线,请使用配送任务状态流转接口")
- @router.post("/orders/{order_id}/confirm-partial", response_model=dict)
- def confirm_partial_delivery(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """已废弃:旧的部分送达确认流程不再支持。"""
- return error_response(410, "该接口已下线,请使用订单确认收货流程")
- @router.post("/orders/{order_id}/create-delivery-task", response_model=dict)
- def create_delivery_task_from_order(
- order_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """从订单创建配送任务"""
- from app.crud.order import order_crud, order_item_crud
- from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- # 只有待分配配送状态可以创建配送任务
- if getattr(order, "delivery_status", None) != "pending_dispatch":
- return error_response(400, "只有待分配配送状态的订单可以创建配送任务")
- # 检查是否已存在配送任务
- existing_task = delivery_task_crud.get_by_order(db, order_id)
- if existing_task:
- return error_response(400, "该订单已存在配送任务")
- # 获取订单关联的价格清单,以获取供应商ID
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, order.price_list_id)
- if not price_list:
- return error_response(400, "订单关联的价格清单不存在")
- supplier_id = price_list.supplier_id
- # 获取订单项
- order_items = order_item_crud.get_by_order(db, order_id)
- # 构建配送任务明细数据
- task_items_data = []
- for item in order_items:
- if item.status == "deleted" or item.is_del != 0:
- continue
- task_items_data.append({
- "order_item_id": item.id,
- "product_id": item.product_id,
- "client_id": order.client_id,
- "delivery_address": order.delivery_address,
- "contact_person": order.contact_person,
- "contact_phone": order.contact_phone,
- "quantity": float(item.quantity) if item.quantity else 0
- })
- creator = {
- "user_id": current_user.get("user_id"),
- "real_name": current_user.get("real_name")
- }
- # 创建配送任务及明细
- task, task_items = delivery_task_crud.create_delivery_task_from_order(
- db,
- order_id=order_id,
- order_items=task_items_data,
- supplier_id=supplier_id,
- region_id=order.region_id,
- creator=creator
- )
- # 阶段D收尾:优先维护三字段,再回写旧 status
- from app.core.order_status_compat import derive_legacy_status_from_three
- order.order_status = "pending_confirm"
- order.delivery_status = "delivering"
- order.status = derive_legacy_status_from_three(
- order_status=order.order_status,
- delivery_status=order.delivery_status,
- pay_status=order.pay_status,
- )
- order.delivery_assign_time = datetime.now()
- order.update_at = datetime.now()
- db.commit()
- return success_response({
- "id": task.id,
- "taskNo": task.task_no,
- "status": task.status,
- "effectiveTime": str(task.effective_time) if task.effective_time else None,
- "message": "配送任务创建成功"
- })
- @router.post("/orders/{order_id}/assign-deliverer", response_model=dict)
- def assign_deliverer_to_order(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """分配配送员并创建配送任务(一步完成)"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- from app.crud.order import order_crud, order_item_crud
- from app.crud.delivery import delivery_task_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- # 检查订单是否被其他操作员锁定
- operator_id = current_user.get("user_id")
- if order.lock_operator_id and order.lock_operator_id != operator_id:
- lock_time = order.lock_time.strftime("%Y-%m-%d %H:%M:%S") if order.lock_time else "未知"
- return error_response(400, f"订单已被其他操作员于 {lock_time} 锁定,请先解锁后再操作")
- deliver_id = request.get("deliverId")
- if not deliver_id:
- return error_response(400, "请选择配送员")
- deadline_raw = request.get("deliveryDeadlineAt") or request.get("delivery_deadline_at")
- if not deadline_raw:
- return error_response(400, "请选择配送时效(截止时间)")
- dispatch_remark_raw = request.get("dispatchRemark") or request.get("dispatch_remark") or ""
- dispatch_remark = str(dispatch_remark_raw).strip()[:500] if dispatch_remark_raw else None
- effective_deadline = None
- try:
- ds = str(deadline_raw).strip().replace("Z", "+00:00")
- effective_deadline = datetime.fromisoformat(ds)
- if effective_deadline.tzinfo is not None:
- effective_deadline = effective_deadline.replace(tzinfo=None)
- except (TypeError, ValueError):
- effective_deadline = None
- if not effective_deadline:
- return error_response(400, "配送时效格式不正确")
- # 验证配送员
- deliver = user_crud.get(db, deliver_id)
- if not deliver:
- return error_response(404, "配送员不存在")
- if deliver.role != "deliver":
- return error_response(400, "该用户不是配送员")
- if not deliver.region_id or deliver.region_id != order.region_id:
- return error_response(400, "只能选择订单所属区域的配送员")
- prev_delivery_status = getattr(order, "delivery_status", None) or "pending_dispatch"
- operator_name = (current_user.get("real_name") or "").strip() or None
- updater = {"user_id": operator_id, "real_name": operator_name}
- # 检查是否已存在配送任务
- existing_task = delivery_task_crud.get_by_order(db, order_id)
- old_deliver_id = existing_task.deliver_id if existing_task else None
- if existing_task:
- task = delivery_task_crud.update_with_history(
- db,
- existing_task.id,
- {
- "deliver_id": deliver_id,
- "status": "assigned",
- "effective_time": effective_deadline,
- "dispatch_remark": dispatch_remark,
- },
- updater=updater,
- )
- if not task:
- return error_response(400, "更新配送任务失败")
- else:
- # 获取供应商ID
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, order.price_list_id)
- if not price_list:
- return error_response(400, "订单关联的价格清单不存在")
- # 获取订单项
- order_items = order_item_crud.get_by_order(db, order_id)
- # 构建配送任务明细数据
- task_items_data = []
- for item in order_items:
- if item.status == "deleted" or item.is_del != 0:
- continue
- task_items_data.append({
- "order_item_id": item.id,
- "product_id": item.product_id,
- "client_id": order.client_id,
- "delivery_address": order.delivery_address,
- "contact_person": order.contact_person,
- "contact_phone": order.contact_phone,
- "quantity": float(item.quantity) if item.quantity else 0
- })
- creator = {
- "user_id": current_user.get("user_id"),
- "real_name": current_user.get("real_name")
- }
- # 创建配送任务
- task, _ = delivery_task_crud.create_delivery_task_from_order(
- db,
- order_id=order_id,
- order_items=task_items_data,
- supplier_id=price_list.supplier_id,
- region_id=order.region_id,
- creator=creator
- )
- # 分配配送员
- task.deliver_id = deliver_id
- task.status = "assigned"
- task.effective_time = effective_deadline
- task.dispatch_remark = dispatch_remark
- task.update_at = datetime.now()
- # 分配配送员后同步双状态;首次分配时供应商从待备货起算,更换配送员不重置备货进度
- if task and task.deliver_id:
- task.deliverer_status = "pending_pickup"
- if not old_deliver_id:
- task.supplier_prepare_status = "pending_prepare"
- delivery_task_crud._sync_legacy_status_from_dual(task)
- # 阶段D收尾:优先维护三字段,再回写旧 status
- from app.core.order_status_compat import derive_legacy_status_from_three
- order.order_status = "pending_confirm"
- if not order.delivery_status or order.delivery_status == "pending_dispatch":
- order.delivery_status = "delivering"
- order.status = derive_legacy_status_from_three(
- order_status=order.order_status,
- delivery_status=order.delivery_status,
- pay_status=order.pay_status,
- )
- order.delivery_assign_time = datetime.now()
- order.update_at = datetime.now()
- def _deliver_display_name(uid: Optional[str]) -> Optional[str]:
- if not uid:
- return None
- u = user_crud.get(db, uid)
- return (u.real_name if u else None) or uid
- if not existing_task:
- order_crud._create_status_log(
- db,
- order_id,
- "",
- "delivery.assign",
- operator_id,
- "admin",
- reason=json.dumps(
- {
- "fromDeliverId": None,
- "toDeliverId": deliver_id,
- "fromDeliverName": None,
- "toDeliverName": deliver.real_name,
- },
- ensure_ascii=False,
- ),
- create_name=operator_name,
- )
- elif old_deliver_id and old_deliver_id != deliver_id:
- order_crud._create_status_log(
- db,
- order_id,
- "",
- "delivery.reassign",
- operator_id,
- "admin",
- reason=json.dumps(
- {
- "fromDeliverId": old_deliver_id,
- "toDeliverId": deliver_id,
- "fromDeliverName": _deliver_display_name(old_deliver_id),
- "toDeliverName": deliver.real_name,
- },
- ensure_ascii=False,
- ),
- create_name=operator_name,
- )
- elif not old_deliver_id and deliver_id:
- order_crud._create_status_log(
- db,
- order_id,
- "",
- "delivery.assign",
- operator_id,
- "admin",
- reason=json.dumps(
- {
- "fromDeliverId": None,
- "toDeliverId": deliver_id,
- "fromDeliverName": None,
- "toDeliverName": deliver.real_name,
- },
- ensure_ascii=False,
- ),
- create_name=operator_name,
- )
- if prev_delivery_status != order.delivery_status:
- order_crud._create_status_log(
- db,
- order_id,
- f"ds_{prev_delivery_status}",
- f"ds_{order.delivery_status}",
- operator_id,
- "admin",
- create_name=operator_name,
- )
- db.commit()
- db.refresh(task)
- return success_response({
- "id": task.id,
- "taskNo": task.task_no,
- "deliverId": task.deliver_id,
- "deliverName": deliver.real_name,
- "status": task.status,
- "effectiveTime": str(task.effective_time) if task.effective_time else None,
- "message": "配送员分配成功"
- })
- @router.post("/orders/{order_id}/assign-delivery", response_model=dict)
- def assign_delivery_alias(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """兼容旧路径:与 assign-deliverer 相同"""
- return assign_deliverer_to_order(order_id, request, db, current_user)
- # =========================================
- # 结算管理 API (Web端)
- # =========================================
- def _build_settlement_no(prefix: str) -> str:
- return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S')}{str(uuid.uuid4().hex)[:6].upper()}"
- def _parse_datetime_range(start_date: Optional[str], end_date: Optional[str]):
- dt_start = None
- dt_end = None
- if start_date:
- try:
- dt_start = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
- except Exception:
- dt_start = None
- if end_date:
- try:
- dt_end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
- except Exception:
- dt_end = None
- return dt_start, dt_end
- def _supplier_unit_yuan_from_pli(pli: Optional[BizPriceListItem]) -> Optional[float]:
- """价格清单行 price 为面向客户的最终单价;pricing_coefficient 为调价系数(%),供货单价 = price / (1 + 系数/100)。"""
- if pli is None or getattr(pli, "price", None) is None:
- return None
- final_price = Decimal(str(pli.price))
- raw_coeff = getattr(pli, "pricing_coefficient", None)
- if raw_coeff is None:
- coeff_pct = Decimal("0")
- else:
- try:
- coeff_pct = Decimal(str(raw_coeff))
- except Exception:
- coeff_pct = Decimal("0")
- factor = Decimal("1") + coeff_pct / Decimal("100")
- if factor <= Decimal("0"):
- factor = Decimal("1")
- supplier_unit = (final_price / factor).quantize(Decimal("0.01"))
- return float(supplier_unit)
- def _price_list_item_map(db: Session, price_list_ids: List[str]) -> dict:
- """(price_list_id, product_id, unit_id) -> BizPriceListItem"""
- if not price_list_ids:
- return {}
- rows = (
- db.query(BizPriceListItem)
- .filter(BizPriceListItem.price_list_id.in_(price_list_ids), BizPriceListItem.is_del == 0)
- .all()
- )
- return {(r.price_list_id, r.product_id, r.unit_id): r for r in rows}
- def _batch_supplier_totals_for_orders(db: Session, order_ids: List[str]) -> dict:
- """订单维度供货价合计(元),用于供应商结算。"""
- if not order_ids:
- return {}
- orders = db.query(BizOrder).filter(BizOrder.id.in_(order_ids), BizOrder.is_del == 0).all()
- om = {o.id: o for o in orders}
- lines = (
- db.query(BizOrderItem)
- .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
- .all()
- )
- by_oid: dict = {}
- for ln in lines:
- by_oid.setdefault(ln.order_id, []).append(ln)
- pl_ids = list({om[oid].price_list_id for oid in order_ids if oid in om and om[oid].price_list_id})
- pli_map = _price_list_item_map(db, pl_ids)
- out: dict = {}
- for oid in order_ids:
- o = om.get(oid)
- if not o:
- out[oid] = 0.0
- continue
- tot = Decimal("0")
- for ln in by_oid.get(oid, []):
- pli = pli_map.get((o.price_list_id, ln.product_id, ln.unit_id))
- sup_u = _supplier_unit_yuan_from_pli(pli)
- if sup_u is None:
- sup_u = float(ln.unit_price or 0)
- qty = Decimal(str(ln.quantity or 0))
- tot += qty * Decimal(str(sup_u))
- out[oid] = float(tot.quantize(Decimal("0.01")))
- return out
- def _build_order_line_children_for_settlement_list(
- db: Session,
- order: BizOrder,
- lines: List[BizOrderItem],
- pli_map: dict,
- product_map: dict,
- unit_map: dict,
- supplier_id: Optional[str] = None,
- supplier_name: Optional[str] = None,
- ) -> List[dict]:
- children: List[dict] = []
- for ln in lines:
- p = product_map.get(ln.product_id)
- u = unit_map.get(ln.unit_id)
- pli = pli_map.get((order.price_list_id, ln.product_id, ln.unit_id))
- sup_u = _supplier_unit_yuan_from_pli(pli)
- if sup_u is None:
- sup_u = float(ln.unit_price or 0)
- sell_u = float(ln.unit_price or 0)
- qty = float(ln.quantity or 0)
- sell_amt = float(ln.amount or 0) if ln.amount is not None else round(qty * sell_u, 2)
- sup_amt = round(qty * sup_u, 2)
- img = None
- if p and getattr(p, "image_url", None):
- img = str(p.image_url).strip() or None
- mv = None
- if p is not None and p.measure_value is not None:
- try:
- mv = float(p.measure_value)
- except (TypeError, ValueError):
- mv = None
- children.append({
- "rowType": "line",
- "id": f"line-{ln.id}",
- "lineId": ln.id,
- "productId": ln.product_id,
- "productName": p.name if p else None,
- "skuCode": p.sku_code if p else None,
- "imageUrl": img,
- "unitName": u.name if u else None,
- "measureValue": mv,
- "quantity": qty,
- "sellingUnitPrice": sell_u,
- "sellingAmount": sell_amt,
- "supplierUnitPrice": sup_u,
- "supplierAmount": sup_amt,
- "supplierId": supplier_id,
- "supplierName": supplier_name,
- })
- return children
- def _build_client_pending_settlement_tree_rows(
- db: Session,
- orders: List[BizOrder],
- client_map: dict,
- ) -> List[dict]:
- if not orders:
- return []
- # 避免模块级循环依赖:在需要处局部导入
- from app.crud.order import order_crud
- order_ids = [o.id for o in orders]
- region_ids = list({o.region_id for o in orders if o.region_id})
- region_map: dict = {}
- for rid in region_ids:
- region = region_crud.get(db, rid)
- region_map[rid] = region.name if region else None
- lines = (
- db.query(BizOrderItem)
- .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
- .order_by(BizOrderItem.order_id.asc(), BizOrderItem.create_at.asc())
- .all()
- )
- by_oid: dict = {}
- for ln in lines:
- by_oid.setdefault(ln.order_id, []).append(ln)
- product_ids = list({ln.product_id for ln in lines})
- unit_ids = list({ln.unit_id for ln in lines})
- products = (
- db.query(BasProduct).filter(BasProduct.id.in_(product_ids), BasProduct.is_del == 0).all()
- ) if product_ids else []
- product_map = {p.id: p for p in products}
- units = (
- db.query(BasProductUnit).filter(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0).all()
- ) if unit_ids else []
- unit_map = {u.id: u for u in units}
- pl_ids = list({o.price_list_id for o in orders if o.price_list_id})
- pli_map = _price_list_item_map(db, pl_ids)
- price_lists = (
- db.query(BizPriceList).filter(BizPriceList.id.in_(pl_ids), BizPriceList.is_del == 0).all()
- ) if pl_ids else []
- supplier_id_by_price_list = {p.id: p.supplier_id for p in price_lists}
- supplier_ids = list({sid for sid in supplier_id_by_price_list.values() if sid})
- suppliers = (
- db.query(SysUser).filter(SysUser.id.in_(supplier_ids), SysUser.is_del == 0).all()
- ) if supplier_ids else []
- supplier_name_map = {u.id: (u.real_name or u.username) for u in suppliers}
- out: List[dict] = []
- for o in orders:
- sid = supplier_id_by_price_list.get(o.price_list_id)
- sname = supplier_name_map.get(sid) if sid else None
- ch = _build_order_line_children_for_settlement_list(
- db, o, by_oid.get(o.id, []), pli_map, product_map, unit_map, sid, sname
- )
- sup_ref = sum(c["supplierAmount"] for c in ch)
- normalized_payment_method = order_crud.normalize_payment_method(o.payment_method)
- sel = (
- o.order_status == "completed"
- and normalized_payment_method == "offline"
- and getattr(o, "client_settlement_status", None) == "pending"
- )
- out.append({
- "rowType": "order",
- "id": o.id,
- "orderNo": o.order_no,
- "clientId": o.client_id,
- "clientName": client_map.get(o.client_id),
- "regionId": o.region_id,
- "regionName": region_map.get(o.region_id),
- "supplierId": supplier_id_by_price_list.get(o.price_list_id),
- "supplierName": supplier_name_map.get(supplier_id_by_price_list.get(o.price_list_id)),
- "totalAmount": float(o.total_amount or 0),
- "settlementAmount": float(o.total_amount or 0),
- "supplierReferenceTotal": round(sup_ref, 2),
- "orderStatus": o.order_status,
- "deliveryStatus": getattr(o, "delivery_status", None) or "pending_dispatch",
- "settlementSelectable": sel,
- "paymentMethod": normalized_payment_method,
- "clientSettlementStatus": o.client_settlement_status,
- "supplierSettlementStatus": o.supplier_settlement_status,
- "createAt": str(o.create_at) if o.create_at else None,
- "children": ch,
- })
- return out
- def _build_supplier_pending_settlement_tree_rows(
- db: Session,
- order_supplier_pairs: List[Tuple[BizOrder, Optional[str]]],
- supplier_map: dict,
- ) -> List[dict]:
- if not order_supplier_pairs:
- return []
- # 避免模块级循环依赖:在需要处局部导入
- from app.crud.order import order_crud
- orders = [p[0] for p in order_supplier_pairs]
- sup_by_oid = {p[0].id: p[1] for p in order_supplier_pairs}
- order_ids = [o.id for o in orders]
- lines = (
- db.query(BizOrderItem)
- .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
- .order_by(BizOrderItem.order_id.asc(), BizOrderItem.create_at.asc())
- .all()
- )
- by_oid: dict = {}
- for ln in lines:
- by_oid.setdefault(ln.order_id, []).append(ln)
- product_ids = list({ln.product_id for ln in lines})
- unit_ids = list({ln.unit_id for ln in lines})
- products = (
- db.query(BasProduct).filter(BasProduct.id.in_(product_ids), BasProduct.is_del == 0).all()
- ) if product_ids else []
- product_map = {p.id: p for p in products}
- units = (
- db.query(BasProductUnit).filter(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0).all()
- ) if unit_ids else []
- unit_map = {u.id: u for u in units}
- pl_ids = list({o.price_list_id for o in orders if o.price_list_id})
- pli_map = _price_list_item_map(db, pl_ids)
- client_ids = list({o.client_id for o in orders if o.client_id})
- client_map: dict = {}
- if client_ids:
- clients = db.query(SysUser).filter(SysUser.id.in_(client_ids), SysUser.is_del == 0).all()
- client_map = {u.id: (u.real_name or u.username) for u in clients}
- region_ids = list({o.region_id for o in orders if o.region_id})
- region_map: dict = {}
- for rid in region_ids:
- region = region_crud.get(db, rid)
- region_map[rid] = region.name if region else None
- out: List[dict] = []
- for o in orders:
- sid = sup_by_oid.get(o.id)
- sname = supplier_map.get(sid) if sid else None
- ch = _build_order_line_children_for_settlement_list(
- db, o, by_oid.get(o.id, []), pli_map, product_map, unit_map, sid, sname
- )
- sup_order_total = sum(c["supplierAmount"] for c in ch)
- normalized_payment_method = order_crud.normalize_payment_method(o.payment_method)
- sel = (
- o.order_status == "completed"
- and normalized_payment_method == "offline"
- and getattr(o, "client_settlement_status", None) == "pending"
- )
- out.append({
- "rowType": "order",
- "id": o.id,
- "orderNo": o.order_no,
- "clientId": o.client_id,
- "clientName": client_map.get(o.client_id),
- "regionId": o.region_id,
- "regionName": region_map.get(o.region_id),
- "supplierId": sid,
- "supplierName": supplier_map.get(sid) if sid else None,
- "settlementAmount": round(sup_order_total, 2),
- "totalAmount": float(o.total_amount or 0),
- "clientSellingTotal": float(o.total_amount or 0),
- "orderStatus": o.order_status,
- "deliveryStatus": getattr(o, "delivery_status", None) or "pending_dispatch",
- "settlementSelectable": sel,
- "supplierSettlementStatus": o.supplier_settlement_status,
- "clientSettlementStatus": o.client_settlement_status,
- "paymentMethod": normalized_payment_method,
- "createAt": str(o.create_at) if o.create_at else None,
- "children": ch,
- })
- return out
- @router.get("/client-settlements/orders", response_model=dict)
- def list_client_settlement_orders(
- page: int = Query(1, ge=1),
- pageSize: int = Query(10, ge=1, le=100),
- clientId: Optional[str] = Query(None),
- keyword: Optional[str] = Query(None, description="订单号"),
- startDate: Optional[str] = Query(None),
- endDate: Optional[str] = Query(None),
- regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选,区域管理员忽略"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- admin_region_id = get_admin_region_filter(db, current_user)
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None and regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
- dt_start, dt_end = _parse_datetime_range(startDate, endDate)
- q = db.query(BizOrder).filter(
- BizOrder.is_del == 0,
- BizOrder.order_status != "cancelled",
- BizOrder.payment_method == "offline",
- BizOrder.client_settlement_status == "pending",
- )
- if eff_region_id:
- q = q.filter(BizOrder.region_id == eff_region_id)
- if clientId:
- q = q.filter(BizOrder.client_id == clientId)
- if keyword:
- q = q.filter(BizOrder.order_no.like(f"%{keyword}%"))
- if dt_start:
- q = q.filter(BizOrder.create_at >= dt_start)
- if dt_end:
- q = q.filter(BizOrder.create_at <= dt_end)
- total = q.count()
- settleable_order_count = q.filter(BizOrder.order_status == "completed").count()
- q_done = q.filter(BizOrder.order_status == "completed")
- pending_amount = float(
- q_done.with_entities(func.coalesce(func.sum(BizOrder.total_amount), 0)).scalar() or 0
- )
- rows = q.order_by(BizOrder.create_at.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
- client_ids = list({r.client_id for r in rows if r.client_id})
- client_map = {}
- if client_ids:
- for u in db.query(SysUser).filter(SysUser.id.in_(client_ids)).all():
- client_map[u.id] = u.real_name or u.username
- data = _build_client_pending_settlement_tree_rows(db, rows, client_map)
- current_page_amount = float(sum((o.total_amount or 0) for o in rows if o.order_status == "completed"))
- return success_response({
- "list": data,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize,
- "summary": {
- "pendingAmount": pending_amount,
- "currentPageAmount": current_page_amount,
- "settleableOrderCount": settleable_order_count,
- },
- })
- @router.get("/supplier-settlements/orders", response_model=dict)
- def list_supplier_settlement_orders(
- page: int = Query(1, ge=1),
- pageSize: int = Query(10, ge=1, le=100),
- supplierId: Optional[str] = Query(None),
- keyword: Optional[str] = Query(None, description="订单号"),
- startDate: Optional[str] = Query(None),
- endDate: Optional[str] = Query(None),
- regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选,区域管理员忽略"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- admin_region_id = get_admin_region_filter(db, current_user)
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None and regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
- dt_start, dt_end = _parse_datetime_range(startDate, endDate)
- q = db.query(BizOrder, BizPriceList.supplier_id).join(
- BizPriceList, BizPriceList.id == BizOrder.price_list_id
- ).filter(
- BizOrder.is_del == 0,
- BizOrder.order_status != "cancelled",
- BizOrder.supplier_settlement_status == "pending",
- )
- if eff_region_id:
- q = q.filter(BizOrder.region_id == eff_region_id)
- if supplierId:
- q = q.filter(BizPriceList.supplier_id == supplierId)
- if keyword:
- q = q.filter(BizOrder.order_no.like(f"%{keyword}%"))
- if dt_start:
- q = q.filter(BizOrder.create_at >= dt_start)
- if dt_end:
- q = q.filter(BizOrder.create_at <= dt_end)
- total = q.count()
- settleable_order_count = q.filter(BizOrder.order_status == "completed").count()
- q_done = q.filter(BizOrder.order_status == "completed")
- done_pairs = q_done.with_entities(BizOrder.id).all()
- done_oids = [x[0] for x in done_pairs]
- totals_map = _batch_supplier_totals_for_orders(db, done_oids)
- pending_amount = float(sum(totals_map.values()))
- rows = q.order_by(BizOrder.create_at.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
- supplier_ids = list({s_id for _, s_id in rows if s_id})
- supplier_map = {}
- if supplier_ids:
- for u in db.query(SysUser).filter(SysUser.id.in_(supplier_ids)).all():
- supplier_map[u.id] = u.real_name or u.username
- data = _build_supplier_pending_settlement_tree_rows(db, rows, supplier_map)
- page_done_ids = [o.id for o, _ in rows if o.order_status == "completed"]
- page_totals = _batch_supplier_totals_for_orders(db, page_done_ids)
- current_page_amount = float(sum(page_totals.values()))
- return success_response({
- "list": data,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize,
- "summary": {
- "pendingAmount": pending_amount,
- "currentPageAmount": current_page_amount,
- "settleableOrderCount": settleable_order_count,
- },
- })
- @router.get("/client-settlements/history-summary", response_model=dict)
- def get_client_settlement_history_summary(
- regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选"),
- clientId: Optional[str] = Query(None, description="客户ID;不传则汇总当前区域下全部客户"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """待结算页用:当前检索条件下已结算批次的历史金额与历史订单笔数。"""
- admin_region_id = get_admin_region_filter(db, current_user)
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None and regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
- amt, cnt = _aggregate_client_settlement_history(db, eff_region_id, clientId)
- return success_response({
- "historicalSettledAmount": amt,
- "historicalSettledOrderCount": cnt,
- })
- @router.get("/supplier-settlements/history-summary", response_model=dict)
- def get_supplier_settlement_history_summary(
- regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选"),
- supplierId: Optional[str] = Query(None, description="供应商ID;不传则汇总当前区域下全部供应商"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """待结算页用:当前检索条件下已结算批次的历史金额与历史订单笔数。"""
- admin_region_id = get_admin_region_filter(db, current_user)
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None and regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
- amt, cnt = _aggregate_supplier_settlement_history(db, eff_region_id, supplierId)
- return success_response({
- "historicalSettledAmount": amt,
- "historicalSettledOrderCount": cnt,
- })
- def _resolve_client_order_ids(db: Session, body: dict, admin_region_id: Optional[str]) -> List[str]:
- selection_mode = (body.get("selectionMode") or "ids").strip()
- filters = body.get("filters") or {}
- eff_region = _effective_settlement_region_id(admin_region_id, filters.get("regionId"))
- if selection_mode == "allFiltered":
- dt_start, dt_end = _parse_datetime_range(filters.get("startDate"), filters.get("endDate"))
- q = db.query(BizOrder.id).filter(
- BizOrder.is_del == 0,
- BizOrder.order_status != "cancelled",
- BizOrder.order_status == "completed",
- BizOrder.payment_method == "offline",
- BizOrder.client_settlement_status == "pending",
- )
- if eff_region:
- q = q.filter(BizOrder.region_id == eff_region)
- if filters.get("clientId"):
- q = q.filter(BizOrder.client_id == filters["clientId"])
- if filters.get("keyword"):
- q = q.filter(BizOrder.order_no.like(f"%{filters['keyword']}%"))
- if dt_start:
- q = q.filter(BizOrder.create_at >= dt_start)
- if dt_end:
- q = q.filter(BizOrder.create_at <= dt_end)
- order_ids = [x[0] for x in q.all()]
- excluded = set(body.get("excludedOrderIds") or [])
- return [oid for oid in order_ids if oid not in excluded]
- return list(set(body.get("orderIds") or []))
- def _resolve_supplier_order_ids(db: Session, body: dict, admin_region_id: Optional[str]) -> List[str]:
- selection_mode = (body.get("selectionMode") or "ids").strip()
- filters = body.get("filters") or {}
- eff_region = _effective_settlement_region_id(admin_region_id, filters.get("regionId"))
- if selection_mode == "allFiltered":
- dt_start, dt_end = _parse_datetime_range(filters.get("startDate"), filters.get("endDate"))
- q = db.query(BizOrder.id).join(
- BizPriceList, BizPriceList.id == BizOrder.price_list_id
- ).filter(
- BizOrder.is_del == 0,
- BizOrder.order_status != "cancelled",
- BizOrder.order_status == "completed",
- BizOrder.supplier_settlement_status == "pending",
- )
- if eff_region:
- q = q.filter(BizOrder.region_id == eff_region)
- if filters.get("supplierId"):
- q = q.filter(BizPriceList.supplier_id == filters["supplierId"])
- if filters.get("keyword"):
- q = q.filter(BizOrder.order_no.like(f"%{filters['keyword']}%"))
- if dt_start:
- q = q.filter(BizOrder.create_at >= dt_start)
- if dt_end:
- q = q.filter(BizOrder.create_at <= dt_end)
- order_ids = [x[0] for x in q.all()]
- excluded = set(body.get("excludedOrderIds") or [])
- return [oid for oid in order_ids if oid not in excluded]
- return list(set(body.get("orderIds") or []))
- @router.post("/client-settlements", response_model=dict)
- def create_client_settlement(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- admin_region_id = get_admin_region_filter(db, current_user)
- body = dict(request or {})
- voucher_urls = body.get("voucherUrls") or []
- order_ids = _resolve_client_order_ids(db, body, admin_region_id)
- if not order_ids:
- return error_response(400, "未选择可结算订单")
- orders = db.query(BizOrder).filter(
- BizOrder.id.in_(order_ids),
- BizOrder.is_del == 0,
- BizOrder.order_status == "completed",
- BizOrder.payment_method == "offline",
- BizOrder.client_settlement_status == "pending",
- ).all()
- if not orders:
- return error_response(400, "无可结算订单")
- eff_region = _effective_settlement_region_id(
- admin_region_id, (body.get("filters") or {}).get("regionId")
- )
- if eff_region and any((o.region_id or "") != eff_region for o in orders):
- return error_response(400, "存在不属于当前区域的订单")
- total_amount = sum((o.total_amount or 0) for o in orders)
- client_id = orders[0].client_id
- if any(o.client_id != client_id for o in orders):
- return error_response(400, "客户结算必须选择同一客户")
- client = user_crud.get(db, client_id)
- now = datetime.now()
- creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- settlement = OrdClientSettlement(
- id=generate_id(),
- settlement_no=_build_settlement_no("CST"),
- client_id=client_id,
- client_name=(client.real_name if client else None),
- order_count=len(orders),
- total_amount=total_amount,
- voucher_urls=json.dumps(voucher_urls, ensure_ascii=False),
- remark=body.get("remark"),
- settlement_at=now,
- status="done",
- create_by=creator["user_id"],
- create_name=creator["real_name"],
- update_by=creator["user_id"],
- )
- db.add(settlement)
- db.flush()
- for order in orders:
- db.add(OrdClientSettlementItem(
- id=generate_id(),
- settlement_id=settlement.id,
- order_id=order.id,
- order_no=order.order_no,
- client_id=order.client_id,
- order_amount=order.total_amount or 0,
- create_by=creator["user_id"],
- create_name=creator["real_name"],
- update_by=creator["user_id"],
- ))
- order.client_settlement_status = "settled"
- order.client_settlement_at = now
- order.update_by = creator["user_id"]
- order.update_at = now
- db.commit()
- return success_response({
- "id": settlement.id,
- "settlementNo": settlement.settlement_no,
- "orderCount": settlement.order_count,
- "totalAmount": float(settlement.total_amount or 0),
- })
- @router.post("/supplier-settlements", response_model=dict)
- def create_supplier_settlement(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- admin_region_id = get_admin_region_filter(db, current_user)
- body = dict(request or {})
- voucher_urls = body.get("voucherUrls") or []
- order_ids = _resolve_supplier_order_ids(db, body, admin_region_id)
- if not order_ids:
- return error_response(400, "未选择可结算订单")
- rows = db.query(BizOrder, BizPriceList.supplier_id).join(
- BizPriceList, BizPriceList.id == BizOrder.price_list_id
- ).filter(
- BizOrder.id.in_(order_ids),
- BizOrder.is_del == 0,
- BizOrder.order_status == "completed",
- BizOrder.supplier_settlement_status == "pending",
- ).all()
- if not rows:
- return error_response(400, "无可结算订单")
- eff_region = _effective_settlement_region_id(
- admin_region_id, (body.get("filters") or {}).get("regionId")
- )
- if eff_region and any((order.region_id or "") != eff_region for order, _ in rows):
- return error_response(400, "存在不属于当前区域的订单")
- supplier_id = rows[0][1]
- if any(s_id != supplier_id for _, s_id in rows):
- return error_response(400, "商家结算必须选择同一供应商")
- orders = [r[0] for r in rows]
- supplier_totals = _batch_supplier_totals_for_orders(db, [o.id for o in orders])
- total_amount = sum(supplier_totals.get(o.id, 0) for o in orders)
- supplier = user_crud.get(db, supplier_id) if supplier_id else None
- now = datetime.now()
- creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
- settlement = OrdSupplierSettlement(
- id=generate_id(),
- settlement_no=_build_settlement_no("SST"),
- supplier_id=supplier_id,
- supplier_name=(supplier.real_name if supplier else None),
- order_count=len(orders),
- total_amount=total_amount,
- voucher_urls=json.dumps(voucher_urls, ensure_ascii=False),
- remark=body.get("remark"),
- settlement_at=now,
- status="done",
- create_by=creator["user_id"],
- create_name=creator["real_name"],
- update_by=creator["user_id"],
- )
- db.add(settlement)
- db.flush()
- for order in orders:
- db.add(OrdSupplierSettlementItem(
- id=generate_id(),
- settlement_id=settlement.id,
- order_id=order.id,
- order_no=order.order_no,
- supplier_id=supplier_id,
- order_amount=supplier_totals.get(order.id, 0),
- create_by=creator["user_id"],
- create_name=creator["real_name"],
- update_by=creator["user_id"],
- ))
- order.supplier_settlement_status = "settled"
- order.supplier_settlement_at = now
- order.update_by = creator["user_id"]
- order.update_at = now
- db.commit()
- return success_response({
- "id": settlement.id,
- "settlementNo": settlement.settlement_no,
- "orderCount": settlement.order_count,
- "totalAmount": float(settlement.total_amount or 0),
- })
- def _sheet_voucher_urls_list(raw) -> list:
- if not raw:
- return []
- if isinstance(raw, list):
- return [str(x) for x in raw if x]
- s = str(raw).strip()
- if not s:
- return []
- try:
- data = json.loads(s)
- if isinstance(data, list):
- return [str(x) for x in data if x]
- except Exception:
- pass
- return [s]
- def _build_settlement_detail_item_tree(db: Session, settlement_items: List[Any]) -> List[dict]:
- """结算单明细:每个订单一行父节点,子行为订单商品行(与前端树表对应)。"""
- from app.crud.order import order_crud
- if not settlement_items:
- return []
- order_ids = list({it.order_id for it in settlement_items if getattr(it, "order_id", None)})
- if not order_ids:
- return []
- orders = (
- db.query(BizOrder)
- .filter(BizOrder.id.in_(order_ids), BizOrder.is_del == 0)
- .all()
- )
- order_map = {o.id: o for o in orders}
- client_ids = list({o.client_id for o in orders if o.client_id})
- client_map: dict = {}
- if client_ids:
- clients = db.query(SysUser).filter(SysUser.id.in_(client_ids), SysUser.is_del == 0).all()
- client_map = {u.id: (u.real_name or u.username) for u in clients}
- region_ids = list({o.region_id for o in orders if o.region_id})
- region_map: dict = {}
- for rid in region_ids:
- r = region_crud.get(db, rid)
- region_map[rid] = r.name if r else None
- lines = (
- db.query(BizOrderItem)
- .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
- .order_by(BizOrderItem.order_id.asc(), BizOrderItem.create_at.asc())
- .all()
- )
- by_order: dict = {}
- for ln in lines:
- by_order.setdefault(ln.order_id, []).append(ln)
- product_ids = list({ln.product_id for ln in lines})
- unit_ids = list({ln.unit_id for ln in lines})
- products = (
- db.query(BasProduct)
- .filter(BasProduct.id.in_(product_ids), BasProduct.is_del == 0)
- .all()
- ) if product_ids else []
- product_map = {p.id: p for p in products}
- category_ids = list({p.category_id for p in products if p.category_id})
- category_map: dict = {}
- for cid in category_ids:
- c = category_crud.get(db, cid)
- category_map[cid] = c.name if c else None
- units = (
- db.query(BasProductUnit)
- .filter(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0)
- .all()
- ) if unit_ids else []
- unit_map = {u.id: u for u in units}
- pl_ids = list({o.price_list_id for o in order_map.values() if o and o.price_list_id})
- pli_map = _price_list_item_map(db, pl_ids)
- price_lists = (
- db.query(BizPriceList).filter(BizPriceList.id.in_(pl_ids), BizPriceList.is_del == 0).all()
- ) if pl_ids else []
- supplier_id_by_price_list = {p.id: p.supplier_id for p in price_lists}
- supplier_ids = list({sid for sid in supplier_id_by_price_list.values() if sid})
- suppliers = (
- db.query(SysUser).filter(SysUser.id.in_(supplier_ids), SysUser.is_del == 0).all()
- ) if supplier_ids else []
- supplier_name_map = {u.id: (u.real_name or u.username) for u in suppliers}
- out: List[dict] = []
- for it in settlement_items:
- order = order_map.get(it.order_id)
- rid = order.region_id if order else None
- children: List[dict] = []
- for ln in by_order.get(it.order_id, []):
- p = product_map.get(ln.product_id)
- u = unit_map.get(ln.unit_id)
- img = None
- if p and getattr(p, "image_url", None):
- img = str(p.image_url).strip() or None
- mv = None
- if p is not None and p.measure_value is not None:
- try:
- mv = float(p.measure_value)
- except (TypeError, ValueError):
- mv = None
- pli = pli_map.get((order.price_list_id, ln.product_id, ln.unit_id)) if order else None
- sup_u = _supplier_unit_yuan_from_pli(pli)
- if sup_u is None:
- sup_u = float(ln.unit_price or 0)
- sell_u = float(ln.unit_price or 0)
- qty = float(ln.quantity or 0)
- sell_amt = float(ln.amount or 0) if ln.amount is not None else round(qty * sell_u, 2)
- sup_amt = round(qty * sup_u, 2)
- supplier_id = supplier_id_by_price_list.get(order.price_list_id) if order else None
- children.append({
- "rowType": "line",
- "id": f"line-{ln.id}",
- "lineId": ln.id,
- "productId": ln.product_id,
- "productName": p.name if p else None,
- "skuCode": p.sku_code if p else None,
- "imageUrl": img,
- "categoryName": category_map.get(p.category_id) if p and p.category_id else None,
- "unitName": u.name if u else None,
- "measureValue": mv,
- "quantity": qty,
- "unitPrice": sell_u,
- "amount": sell_amt,
- "sellingUnitPrice": sell_u,
- "sellingAmount": sell_amt,
- "supplierUnitPrice": sup_u,
- "supplierAmount": sup_amt,
- "supplierId": supplier_id,
- "supplierName": supplier_name_map.get(supplier_id),
- "deliveredQuantity": float(ln.delivered_quantity or 0),
- "note": ln.note,
- })
- supplier_id = supplier_id_by_price_list.get(order.price_list_id) if order else None
- out.append({
- "rowType": "order",
- "id": f"order-{it.id}",
- "settlementItemId": it.id,
- "orderId": it.order_id,
- "orderNo": it.order_no,
- "orderAmount": float(it.order_amount or 0),
- "totalAmount": float(it.order_amount or 0),
- "clientId": order.client_id if order else None,
- "clientName": client_map.get(order.client_id) if order else None,
- "supplierId": supplier_id,
- "supplierName": supplier_name_map.get(supplier_id),
- "regionId": rid,
- "regionName": region_map.get(rid),
- "orderCreateAt": str(order.create_at) if order and order.create_at else None,
- "contactPerson": order.contact_person if order else None,
- "contactPhone": order.contact_phone if order else None,
- "deliveryAddress": order.delivery_address if order else None,
- "orderStatus": order.order_status if order else None,
- "deliveryStatus": (getattr(order, "delivery_status", None) or "pending_dispatch") if order else None,
- "paymentMethod": order_crud.normalize_payment_method(order.payment_method) if order else None,
- "createAt": str(it.create_at) if it.create_at else None,
- "children": children,
- })
- return out
- @router.get("/client-settlements/sheets", response_model=dict)
- def list_client_settlement_sheets(
- page: int = Query(1, ge=1),
- pageSize: int = Query(10, ge=1, le=100),
- clientId: Optional[str] = Query(None),
- settlementNo: Optional[str] = Query(None),
- startDate: Optional[str] = Query(None),
- endDate: Optional[str] = Query(None),
- regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """客户结算单(批次)列表"""
- from app.crud.ord_settlement import ord_client_settlement_crud
- admin_region_id = get_admin_region_filter(db, current_user)
- eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
- dt_start, dt_end = _parse_datetime_range(startDate, endDate)
- rows, total = ord_client_settlement_crud.get_multi_with_filters(
- db,
- client_id=clientId,
- settlement_no=settlementNo,
- start_date=dt_start,
- end_date=dt_end,
- region_id=eff_region_id,
- skip=(page - 1) * pageSize,
- limit=pageSize,
- )
- out = []
- for s in rows:
- out.append({
- "id": s.id,
- "settlementNo": s.settlement_no,
- "clientId": s.client_id,
- "clientName": s.client_name,
- "orderCount": s.order_count,
- "totalAmount": float(s.total_amount or 0),
- "settlementAt": str(s.settlement_at) if s.settlement_at else None,
- "status": s.status,
- "remark": s.remark,
- "createAt": str(s.create_at) if s.create_at else None,
- "createName": s.create_name,
- })
- return success_response({
- "list": out,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize,
- })
- @router.get("/client-settlements/sheets/{sheet_id}", response_model=dict)
- def get_client_settlement_sheet_detail(
- sheet_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """客户结算单详情(含明细订单)"""
- from app.crud.ord_settlement import ord_client_settlement_crud
- admin_region_id = get_admin_region_filter(db, current_user)
- main, items = ord_client_settlement_crud.get_with_items(db, sheet_id)
- if not main:
- return error_response(404, "结算单不存在")
- if admin_region_id and not ord_client_settlement_crud.belongs_to_region(db, sheet_id, admin_region_id):
- return error_response(403, "无权限查看该结算单")
- item_out = _build_settlement_detail_item_tree(db, items)
- return success_response({
- "id": main.id,
- "settlementNo": main.settlement_no,
- "clientId": main.client_id,
- "clientName": main.client_name,
- "orderCount": main.order_count,
- "totalAmount": float(main.total_amount or 0),
- "voucherUrls": _sheet_voucher_urls_list(main.voucher_urls),
- "remark": main.remark,
- "settlementAt": str(main.settlement_at) if main.settlement_at else None,
- "status": main.status,
- "createAt": str(main.create_at) if main.create_at else None,
- "createName": main.create_name,
- "items": item_out,
- })
- @router.put("/client-settlements/sheets/{sheet_id}", response_model=dict)
- def update_client_settlement_sheet(
- sheet_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """更新客户结算单(备注/凭证);UPDATE 前写入 ord_client_settlement_his"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- from app.crud.ord_settlement import ord_client_settlement_crud
- admin_region_id = get_admin_region_filter(db, current_user)
- if admin_region_id and not ord_client_settlement_crud.belongs_to_region(db, sheet_id, admin_region_id):
- return error_response(403, "无权限操作该结算单")
- body = dict(request or {})
- update_data = {}
- if "remark" in body:
- update_data["remark"] = body.get("remark")
- if "voucherUrls" in body:
- urls = body.get("voucherUrls") or []
- if not isinstance(urls, list):
- return error_response(400, "voucherUrls 须为数组")
- update_data["voucher_urls"] = json.dumps(urls, ensure_ascii=False)
- if not update_data:
- return error_response(400, "无可更新字段")
- updater = {"user_id": current_user.get("user_id")}
- row = ord_client_settlement_crud.update_with_history(db, sheet_id, update_data, updater)
- if not row:
- return error_response(404, "结算单不存在")
- return success_response({
- "id": row.id,
- "settlementNo": row.settlement_no,
- "remark": row.remark,
- "voucherUrls": _sheet_voucher_urls_list(row.voucher_urls),
- })
- @router.get("/supplier-settlements/sheets", response_model=dict)
- def list_supplier_settlement_sheets(
- page: int = Query(1, ge=1),
- pageSize: int = Query(10, ge=1, le=100),
- supplierId: Optional[str] = Query(None),
- settlementNo: Optional[str] = Query(None),
- startDate: Optional[str] = Query(None),
- endDate: Optional[str] = Query(None),
- regionId: Optional[str] = Query(None, description="区域(Tab);超级管理员可选"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """供应商结算单(批次)列表"""
- from app.crud.ord_settlement import ord_supplier_settlement_crud
- admin_region_id = get_admin_region_filter(db, current_user)
- eff_region_id = _effective_settlement_region_id(admin_region_id, regionId)
- dt_start, dt_end = _parse_datetime_range(startDate, endDate)
- rows, total = ord_supplier_settlement_crud.get_multi_with_filters(
- db,
- supplier_id=supplierId,
- settlement_no=settlementNo,
- start_date=dt_start,
- end_date=dt_end,
- region_id=eff_region_id,
- skip=(page - 1) * pageSize,
- limit=pageSize,
- )
- out = []
- for s in rows:
- out.append({
- "id": s.id,
- "settlementNo": s.settlement_no,
- "supplierId": s.supplier_id,
- "supplierName": s.supplier_name,
- "orderCount": s.order_count,
- "totalAmount": float(s.total_amount or 0),
- "settlementAt": str(s.settlement_at) if s.settlement_at else None,
- "status": s.status,
- "remark": s.remark,
- "createAt": str(s.create_at) if s.create_at else None,
- "createName": s.create_name,
- })
- return success_response({
- "list": out,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize,
- })
- @router.get("/supplier-settlements/sheets/{sheet_id}", response_model=dict)
- def get_supplier_settlement_sheet_detail(
- sheet_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """供应商结算单详情(含明细订单)"""
- from app.crud.ord_settlement import ord_supplier_settlement_crud
- admin_region_id = get_admin_region_filter(db, current_user)
- main, items = ord_supplier_settlement_crud.get_with_items(db, sheet_id)
- if not main:
- return error_response(404, "结算单不存在")
- if admin_region_id and not ord_supplier_settlement_crud.belongs_to_region(db, sheet_id, admin_region_id):
- return error_response(403, "无权限查看该结算单")
- item_out = _build_settlement_detail_item_tree(db, items)
- return success_response({
- "id": main.id,
- "settlementNo": main.settlement_no,
- "supplierId": main.supplier_id,
- "supplierName": main.supplier_name,
- "orderCount": main.order_count,
- "totalAmount": float(main.total_amount or 0),
- "voucherUrls": _sheet_voucher_urls_list(main.voucher_urls),
- "remark": main.remark,
- "settlementAt": str(main.settlement_at) if main.settlement_at else None,
- "status": main.status,
- "createAt": str(main.create_at) if main.create_at else None,
- "createName": main.create_name,
- "items": item_out,
- })
- @router.put("/supplier-settlements/sheets/{sheet_id}", response_model=dict)
- def update_supplier_settlement_sheet(
- sheet_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin),
- ):
- """更新供应商结算单(备注/凭证);UPDATE 前写入 ord_supplier_settlement_his"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- from app.crud.ord_settlement import ord_supplier_settlement_crud
- admin_region_id = get_admin_region_filter(db, current_user)
- if admin_region_id and not ord_supplier_settlement_crud.belongs_to_region(db, sheet_id, admin_region_id):
- return error_response(403, "无权限操作该结算单")
- body = dict(request or {})
- update_data = {}
- if "remark" in body:
- update_data["remark"] = body.get("remark")
- if "voucherUrls" in body:
- urls = body.get("voucherUrls") or []
- if not isinstance(urls, list):
- return error_response(400, "voucherUrls 须为数组")
- update_data["voucher_urls"] = json.dumps(urls, ensure_ascii=False)
- if not update_data:
- return error_response(400, "无可更新字段")
- updater = {"user_id": current_user.get("user_id")}
- row = ord_supplier_settlement_crud.update_with_history(db, sheet_id, update_data, updater)
- if not row:
- return error_response(404, "结算单不存在")
- return success_response({
- "id": row.id,
- "settlementNo": row.settlement_no,
- "remark": row.remark,
- "voucherUrls": _sheet_voucher_urls_list(row.voucher_urls),
- })
- @router.get("/settlements", response_model=dict)
- def list_settlements(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- clientId: Optional[str] = Query(None, description="客户ID"),
- status: Optional[str] = Query(None, description="状态"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取线下结算订单列表"""
- from app.crud.settlement import settlement_crud
- settlements, total = settlement_crud.get_multi_with_filters(
- db,
- region_id=regionId,
- client_id=clientId,
- status=status,
- start_date=startDate,
- end_date=endDate,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- region_map = {}
- client_map = {}
- result = []
- for settlement in settlements:
- if settlement.region_id and settlement.region_id not in region_map:
- region = region_crud.get(db, settlement.region_id)
- region_map[settlement.region_id] = region.name if region else None
- if settlement.client_id and settlement.client_id not in client_map:
- client = user_crud.get(db, settlement.client_id)
- client_map[settlement.client_id] = client.real_name if client else None
- result.append({
- "id": settlement.id,
- "orderId": settlement.order_id,
- "clientId": settlement.client_id,
- "clientName": client_map.get(settlement.client_id),
- "regionId": settlement.region_id,
- "regionName": region_map.get(settlement.region_id),
- "amount": float(settlement.amount) if settlement.amount else 0,
- "settlementMethod": settlement.settlement_method,
- "settlementTime": str(settlement.settlement_time) if settlement.settlement_time else None,
- "settlementNote": settlement.settlement_note,
- "status": settlement.status,
- "reviewedBy": settlement.reviewed_by,
- "reviewedTime": str(settlement.reviewed_time) if settlement.reviewed_time else None,
- "createAt": str(settlement.create_at) if settlement.create_at else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/settlements/client-summary", response_model=dict)
- def get_client_summary(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- clientId: Optional[str] = Query(None, description="客户ID"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取客户待结算汇总"""
- from app.crud.settlement import settlement_crud
- summaries, total = settlement_crud.get_client_summary(
- db,
- region_id=regionId,
- client_id=clientId,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- result = []
- for summary in summaries:
- client = user_crud.get(db, summary["client_id"])
- result.append({
- "clientId": summary["client_id"],
- "clientName": client.real_name if client else None,
- "orderCount": summary["order_count"],
- "totalUnpaid": float(summary["total_unpaid"]) if summary["total_unpaid"] else 0
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.post("/settlements", response_model=dict)
- def create_settlement(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """确认线下结算"""
- from app.crud.settlement import settlement_crud
- from datetime import datetime
- from decimal import Decimal
- order_id = request.get("orderId")
- settlement_method = request.get("settlementMethod")
- settlement_time_str = request.get("settlementTime")
- note = request.get("settlementNote")
- attachment_url = request.get("attachmentUrl")
- if not order_id:
- return error_response(400, "请选择订单")
- if not settlement_method:
- return error_response(400, "请选择结算方式")
- # 获取订单信息
- from app.models.models import BizOrder
- order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
- if not order:
- return error_response(404, "订单不存在")
- # 计算未付金额
- unpaid_amount = (order.receivable_amount or Decimal("0")) - (order.paid_amount or Decimal("0"))
- if unpaid_amount <= 0:
- return error_response(400, "该订单已无欠款,无需结算")
- # 解析结算时间
- settlement_time = None
- if settlement_time_str:
- try:
- settlement_time = datetime.fromisoformat(settlement_time_str.replace("Z", "+00:00"))
- except:
- settlement_time = datetime.now()
- creator = {
- "user_id": current_user.get("user_id"),
- "real_name": current_user.get("real_name")
- }
- settlement = settlement_crud.create_settlement(
- db,
- order_id=order_id,
- client_id=order.client_id,
- region_id=order.region_id,
- amount=unpaid_amount,
- settlement_method=settlement_method,
- settlement_time=settlement_time,
- attachment_url=attachment_url,
- note=note,
- creator=creator
- )
- return success_response({
- "id": settlement.id,
- "message": "结算成功"
- })
- @router.post("/settlements/batch", response_model=dict)
- def batch_create_settlements(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """批量确认线下结算"""
- from app.crud.settlement import settlement_crud
- from datetime import datetime
- order_ids = request.get("orderIds", [])
- settlement_method = request.get("settlementMethod")
- settlement_time_str = request.get("settlementTime")
- note = request.get("settlementNote")
- attachment_url = request.get("attachmentUrl")
- if not order_ids:
- return error_response(400, "请选择要结算的订单")
- if not settlement_method:
- return error_response(400, "请选择结算方式")
- if len(order_ids) > 50:
- return error_response(400, "单次最多结算50个订单")
- # 解析结算时间
- settlement_time = None
- if settlement_time_str:
- try:
- settlement_time = datetime.fromisoformat(settlement_time_str.replace("Z", "+00:00"))
- except:
- settlement_time = datetime.now()
- creator = {
- "user_id": current_user.get("user_id"),
- "real_name": current_user.get("real_name")
- }
- settlements = settlement_crud.batch_create_settlements(
- db,
- order_ids=order_ids,
- settlement_method=settlement_method,
- settlement_time=settlement_time,
- attachment_url=attachment_url,
- note=note,
- creator=creator
- )
- return success_response({
- "count": len(settlements),
- "message": f"成功结算{len(settlements)}个订单"
- })
- @router.get("/settlements/records", response_model=dict)
- def list_settlement_records(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- clientId: Optional[str] = Query(None, description="客户ID"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取结算记录列表"""
- from app.crud.settlement import settlement_crud
- settlements, total = settlement_crud.get_multi_with_filters(
- db,
- region_id=regionId,
- client_id=clientId,
- status="confirmed",
- start_date=startDate,
- end_date=endDate,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- region_map = {}
- client_map = {}
- reviewer_map = {}
- result = []
- for settlement in settlements:
- if settlement.region_id and settlement.region_id not in region_map:
- region = region_crud.get(db, settlement.region_id)
- region_map[settlement.region_id] = region.name if region else None
- if settlement.client_id and settlement.client_id not in client_map:
- client = user_crud.get(db, settlement.client_id)
- client_map[settlement.client_id] = client.real_name if client else None
- if settlement.reviewed_by and settlement.reviewed_by not in reviewer_map:
- reviewer = user_crud.get(db, settlement.reviewed_by)
- reviewer_map[settlement.reviewed_by] = reviewer.real_name if reviewer else None
- result.append({
- "id": settlement.id,
- "orderId": settlement.order_id,
- "clientId": settlement.client_id,
- "clientName": client_map.get(settlement.client_id),
- "regionId": settlement.region_id,
- "regionName": region_map.get(settlement.region_id),
- "amount": float(settlement.amount) if settlement.amount else 0,
- "settlementMethod": settlement.settlement_method,
- "settlementTime": str(settlement.settlement_time) if settlement.settlement_time else None,
- "settlementNote": settlement.settlement_note,
- "settlementFileUrl": settlement.settlement_file_url,
- "reviewedBy": settlement.reviewed_by,
- "reviewedByName": reviewer_map.get(settlement.reviewed_by),
- "reviewedTime": str(settlement.reviewed_time) if settlement.reviewed_time else None,
- "createAt": str(settlement.create_at) if settlement.create_at else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/settlements/statistics", response_model=dict)
- def get_settlement_statistics(
- regionId: Optional[str] = Query(None, description="区域ID"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取结算统计数据"""
- from app.crud.settlement import settlement_crud
- # 区域管理员只能查看自己区域的统计数据
- admin_region_id = get_admin_region_filter(db, current_user)
- if admin_region_id and not regionId:
- regionId = admin_region_id
- elif admin_region_id and regionId != admin_region_id:
- return success_response({
- "pendingOrderCount": 0,
- "pendingAmount": 0,
- "settledCount": 0,
- "settledAmount": 0,
- "refundPendingCount": 0
- })
- stats = settlement_crud.get_statistics(
- db,
- region_id=regionId,
- start_date=startDate,
- end_date=endDate
- )
- return success_response(stats)
- # =========================================
- # 退款管理 API (Web端)
- # =========================================
- @router.get("/refunds", response_model=dict)
- def list_refunds(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- status: Optional[str] = Query(None, description="状态"),
- orderNo: Optional[str] = Query(None, description="订单号"),
- refundMethod: Optional[str] = Query(None, description="退款方式"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取退款记录列表"""
- from app.crud.settlement import refund_crud
- from app.models.models import BizOrder
- refunds, total = refund_crud.get_multi_with_filters(
- db,
- region_id=regionId,
- order_no=orderNo,
- status=status,
- refund_method=refundMethod,
- start_date=startDate,
- end_date=endDate,
- skip=(page - 1) * pageSize,
- limit=pageSize
- )
- order_map = {}
- result = []
- for refund in refunds:
- if refund.order_id and refund.order_id not in order_map:
- order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
- if order:
- client = user_crud.get(db, order.client_id)
- order_map[refund.order_id] = {
- "orderNo": order.order_no,
- "clientId": order.client_id,
- "clientName": client.real_name if client else None,
- "regionId": order.region_id,
- "totalAmount": float(order.total_amount) if order.total_amount else 0,
- "paidAmount": float(order.paid_amount) if order.paid_amount else 0
- }
- order_info = order_map.get(refund.order_id, {})
- result.append({
- "id": refund.id,
- "orderId": refund.order_id,
- "orderNo": order_info.get("orderNo"),
- "clientId": order_info.get("clientId"),
- "clientName": order_info.get("clientName"),
- "regionId": order_info.get("regionId"),
- "totalAmount": order_info.get("totalAmount"),
- "paidAmount": order_info.get("paidAmount"),
- "refundNo": refund.refund_no,
- "refundAmount": float(refund.refund_amount) if refund.refund_amount else 0,
- "refundMethod": refund.refund_method,
- "refundResult": refund.refund_result,
- "refundTime": str(refund.refund_time) if refund.refund_time else None,
- "refundNoWechat": refund.refund_no_wechat,
- "refundFailReason": refund.refund_fail_reason,
- "status": refund.status,
- "createAt": str(refund.create_at) if refund.create_at else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/refunds/{refund_id}", response_model=dict)
- def get_refund(
- refund_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取退款详情"""
- from app.crud.settlement import refund_crud
- from app.models.models import BizOrder
- refund = refund_crud.get(db, refund_id)
- if not refund:
- return error_response(404, "退款记录不存在")
- order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
- client = user_crud.get(db, order.client_id) if order else None
- return success_response({
- "id": refund.id,
- "orderId": refund.order_id,
- "orderNo": order.order_no if order else None,
- "clientId": order.client_id if order else None,
- "clientName": client.real_name if client else None,
- "totalAmount": float(order.total_amount) if order and order.total_amount else 0,
- "paidAmount": float(order.paid_amount) if order and order.paid_amount else 0,
- "refundNo": refund.refund_no,
- "refundAmount": float(refund.refund_amount) if refund.refund_amount else 0,
- "refundMethod": refund.refund_method,
- "refundResult": refund.refund_result,
- "refundTime": str(refund.refund_time) if refund.refund_time else None,
- "refundNoWechat": refund.refund_no_wechat,
- "refundFailReason": refund.refund_fail_reason,
- "status": refund.status,
- "createAt": str(refund.create_at) if refund.create_at else None,
- "createName": refund.create_name
- })
- @router.post("/refunds/wechat-refund/{order_id}", response_model=dict)
- def create_wechat_refund(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """微信退款"""
- from app.crud.settlement import refund_crud
- from datetime import datetime
- from decimal import Decimal
- refund_amount = request.get("refundAmount")
- reason = request.get("reason")
- if not refund_amount or refund_amount <= 0:
- return error_response(400, "退款金额必须大于0")
- # 获取订单信息
- from app.models.models import BizOrder
- order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
- if not order:
- return error_response(404, "订单不存在")
- # 检查已支付金额
- paid_amount = float(order.paid_amount) if order.paid_amount else 0
- if refund_amount > paid_amount:
- return error_response(400, "退款金额不能超过已支付金额")
- # 检查是否有处理中的退款
- existing_refunds = refund_crud.get_by_order(db, order_id)
- processing_refund = any(r.status in ["pending", "processing"] for r in existing_refunds)
- if processing_refund:
- return error_response(400, "该订单有退款正在处理中,请勿重复提交")
- creator = {
- "user_id": current_user.get("user_id"),
- "real_name": current_user.get("real_name")
- }
- refund = refund_crud.create_refund(
- db,
- order_id=order_id,
- refund_amount=Decimal(str(refund_amount)),
- refund_method="wechat",
- reason=reason,
- creator=creator
- )
- # 模拟微信退款处理(实际项目中需要调用微信支付API)
- refund_crud.process_refund(
- db,
- refund_id=refund.id,
- refund_result="success",
- refund_no_wechat=f"WXREF{datetime.now().strftime('%Y%m%d%H%M%S')}"
- )
- return success_response({
- "refundId": refund.id,
- "refundNo": refund.refund_no,
- "refundAmount": float(refund.refund_amount),
- "refundResult": "success",
- "refundTime": str(refund.refund_time)
- })
- @router.get("/refunds/{refund_id}/status", response_model=dict)
- def get_refund_status(
- refund_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """查询退款状态"""
- from app.crud.settlement import refund_crud
- refund = refund_crud.get(db, refund_id)
- if not refund:
- return error_response(404, "退款记录不存在")
- return success_response({
- "refundId": refund.id,
- "refundNo": refund.refund_no,
- "refundAmount": float(refund.refund_amount) if refund.refund_amount else 0,
- "refundResult": refund.refund_result,
- "refundTime": str(refund.refund_time) if refund.refund_time else None,
- "refundNoWechat": refund.refund_no_wechat
- })
- @router.post("/refunds/{refund_id}/process", response_model=dict)
- def process_refund(
- refund_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """处理退款(成功/失败)"""
- from app.crud.settlement import refund_crud
- refund_result = request.get("refundResult")
- refund_no_wechat = request.get("refundNoWechat")
- if not refund_result or refund_result not in ["success", "failed"]:
- return error_response(400, "请提供正确的退款结果")
- refund = refund_crud.process_refund(
- db,
- refund_id=refund_id,
- refund_result=refund_result,
- refund_no_wechat=refund_no_wechat
- )
- if not refund:
- return error_response(404, "退款记录不存在")
- return success_response({
- "refundId": refund.id,
- "refundResult": refund.refund_result,
- "message": "处理成功"
- })
- @router.post("/refunds/{refund_id}/handle-failure", response_model=dict)
- def handle_refund_failure(
- refund_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """处理退款失败"""
- from app.crud.settlement import refund_crud
- fail_reason = request.get("failReason")
- if not fail_reason:
- return error_response(400, "请提供失败原因")
- refund = refund_crud.handle_refund_failure(
- db,
- refund_id=refund_id,
- fail_reason=fail_reason
- )
- if not refund:
- return error_response(404, "退款记录不存在")
- return success_response({
- "refundId": refund.id,
- "refundResult": refund.refund_result,
- "message": "处理成功"
- })
- @router.get("/refunds/statistics", response_model=dict)
- def get_refund_statistics(
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取退款统计数据"""
- from app.crud.settlement import refund_crud
- stats = refund_crud.get_refund_statistics(
- db,
- start_date=startDate,
- end_date=endDate
- )
- return success_response(stats)
- # =========================================
- # 配送时效预警 API
- # =========================================
- @router.get("/delivery-warnings", response_model=dict)
- def list_delivery_warnings(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- urgencyLevel: Optional[str] = Query(None, description="紧急程度: critical/urgent/normal"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取配送时效预警列表"""
- from app.crud.delivery import delivery_task_crud
- from datetime import datetime, timedelta
- # 计算时间阈值
- now = datetime.now()
- critical_time = now + timedelta(minutes=30) # 30分钟内
- urgent_time = now + timedelta(minutes=60) # 1小时内
- normal_time = now + timedelta(hours=2) # 2小时内
- # 获取所有待配送任务
- query = db.query(delivery_task_crud.model).filter(
- and_(
- delivery_task_crud.model.is_del == 0,
- delivery_task_crud.model.status.in_(["pending", "assigned", "picking", "picked"]),
- delivery_task_crud.model.effective_time != None,
- delivery_task_crud.model.effective_time >= now
- )
- )
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None:
- if regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- if not regionId and allowed_region_ids:
- regionId = sorted(allowed_region_ids)[0]
- if regionId:
- query = query.filter(delivery_task_crud.model.region_id == regionId)
- all_tasks = query.order_by(delivery_task_crud.model.effective_time.asc()).all()
- # 计算紧急程度
- result = []
- for task in all_tasks:
- time_diff = (task.effective_time - now).total_seconds() / 60 # 分钟
- if time_diff <= 30:
- urgency = "critical"
- remaining_minutes = int(time_diff)
- elif time_diff <= 60:
- urgency = "urgent"
- remaining_minutes = int(time_diff)
- else:
- urgency = "normal"
- remaining_minutes = int(time_diff)
- # 按紧急程度过滤
- if urgencyLevel and urgency != urgencyLevel:
- continue
- # 获取关联信息
- client_name = None
- if task.order_id:
- from app.crud.order import order_crud
- order = order_crud.get(db, task.order_id)
- if order:
- client = user_crud.get(db, order.client_id)
- client_name = client.real_name if client else None
- region_name = None
- if task.region_id:
- region = region_crud.get(db, task.region_id)
- region_name = region.name if region else None
- deliver_name = None
- if task.deliver_id:
- deliver = user_crud.get(db, task.deliver_id)
- deliver_name = deliver.real_name if deliver else None
- supplier_name = None
- if task.supplier_id:
- supplier = user_crud.get(db, task.supplier_id)
- supplier_name = supplier.real_name if supplier else None
- result.append({
- "id": task.id,
- "taskNo": task.task_no,
- "orderId": task.order_id,
- "regionId": task.region_id,
- "regionName": region_name,
- "clientName": client_name,
- "supplierId": task.supplier_id,
- "supplierName": supplier_name,
- "deliverId": task.deliver_id,
- "deliverName": deliver_name,
- "status": task.status,
- "effectiveTime": str(task.effective_time) if task.effective_time else None,
- "remainingMinutes": remaining_minutes,
- "urgencyLevel": urgency,
- "isAssigned": task.deliver_id is not None,
- "createAt": str(task.create_at) if task.create_at else None
- })
- # 手动分页
- total = len(result)
- start_idx = (page - 1) * pageSize
- end_idx = start_idx + pageSize
- paginated_result = result[start_idx:end_idx]
- return success_response({
- "list": paginated_result,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/delivery-warnings/statistics", response_model=dict)
- def get_delivery_warning_statistics(
- regionId: Optional[str] = Query(None, description="区域ID"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取配送时效预警统计"""
- from app.crud.delivery import delivery_task_crud
- from datetime import datetime, timedelta
- now = datetime.now()
- # 基础查询
- base_query = db.query(delivery_task_crud.model).filter(
- and_(
- delivery_task_crud.model.is_del == 0,
- delivery_task_crud.model.status.in_(["pending", "assigned", "picking", "picked"]),
- delivery_task_crud.model.effective_time != None,
- delivery_task_crud.model.effective_time >= now
- )
- )
- allowed_region_ids = get_admin_allowed_region_ids(db, current_user)
- if allowed_region_ids is not None:
- if regionId and regionId not in allowed_region_ids:
- return error_response(403, "无权访问该区域数据")
- if not regionId and allowed_region_ids:
- regionId = sorted(allowed_region_ids)[0]
- if regionId:
- base_query = base_query.filter(delivery_task_crud.model.region_id == regionId)
- all_tasks = base_query.all()
- critical_count = 0
- urgent_count = 0
- normal_count = 0
- unassigned_count = 0
- total_pending = len(all_tasks)
- for task in all_tasks:
- time_diff = (task.effective_time - now).total_seconds() / 60
- if time_diff <= 30:
- critical_count += 1
- elif time_diff <= 60:
- urgent_count += 1
- else:
- normal_count += 1
- if not task.deliver_id:
- unassigned_count += 1
- return success_response({
- "totalPending": total_pending,
- "criticalCount": critical_count,
- "urgentCount": urgent_count,
- "normalCount": normal_count,
- "unassignedCount": unassigned_count
- })
- @router.post("/delivery-warnings/{task_id}/reassign", response_model=dict)
- def reassign_delivery_task(
- task_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """紧急重新分配配送任务"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- from app.crud.delivery import delivery_task_crud
- new_deliver_id = request.get("deliverId")
- reason = request.get("reason")
- if not new_deliver_id:
- return error_response(400, "请选择新的配送员")
- task = delivery_task_crud.get(db, task_id)
- if not task:
- return error_response(404, "任务不存在")
- if task.status in ["completed", "cancelled"]:
- return error_response(400, "已完成或已取消的任务无法重新分配")
- # 验证新配送员
- new_deliver = user_crud.get(db, new_deliver_id)
- if not new_deliver:
- return error_response(404, "配送员不存在")
- if new_deliver.role != "deliver":
- return error_response(400, "该用户不是配送员")
- old_deliver_id = task.deliver_id
- old_deliver_name = None
- if old_deliver_id:
- old_deliver = user_crud.get(db, old_deliver_id)
- old_deliver_name = old_deliver.real_name if old_deliver else None
- # 更新任务(换配送员:只重置配送员侧待取货,不重置供应商备货进度)
- task.deliver_id = new_deliver_id
- task.update_at = datetime.now()
- task.deliverer_status = "pending_pickup"
- delivery_task_crud._sync_legacy_status_from_dual(task)
- # 如果之前没有分配配送员,设置新的截止时间
- if not old_deliver_id:
- task.effective_time = datetime.now() + timedelta(hours=4)
- db.commit()
- db.refresh(task)
- return success_response({
- "id": task.id,
- "taskNo": task.task_no,
- "newDeliverId": new_deliver_id,
- "newDeliverName": new_deliver.real_name,
- "oldDeliverId": old_deliver_id,
- "oldDeliverName": old_deliver_name,
- "message": "任务重新分配成功"
- })
- def get_client_ip(request: Request) -> str:
- """获取客户端IP地址"""
- forwarded = request.headers.get("X-Forwarded-For")
- if forwarded:
- return forwarded.split(",")[0].strip()
- return request.client.host if request.client else "unknown"
- # =========================================
- # API 请求日志(中间件落库,此处仅查询/删除)
- # =========================================
- @router.get("/api-request-logs", response_model=dict)
- def list_api_request_logs(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- httpMethod: Optional[str] = Query(None, description="HTTP 方法"),
- statusCode: Optional[int] = Query(None, description="HTTP 状态码"),
- pathPrefix: Optional[str] = Query(None, description="路径包含"),
- keyword: Optional[str] = Query(None, description="关键词"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """API 请求日志列表"""
- from app.crud.api_request_log import api_request_log_crud
- logs, total = api_request_log_crud.get_multi_with_filters(
- db,
- http_method=httpMethod,
- status_code=statusCode,
- path_prefix=pathPrefix,
- keyword=keyword,
- start_date=startDate,
- end_date=endDate,
- skip=(page - 1) * pageSize,
- limit=pageSize,
- )
- return success_response({
- "list": [{
- "id": log.id,
- "httpMethod": log.http_method,
- "path": log.path,
- "queryString": log.query_string,
- "statusCode": log.status_code,
- "durationMs": log.duration_ms,
- "userId": log.user_id,
- "username": log.username,
- "role": log.role,
- "ipAddress": log.ip_address,
- "userAgent": log.user_agent,
- "requestBody": log.request_body,
- "responseBody": log.response_body,
- "createAt": str(log.create_at) if log.create_at else None,
- } for log in logs],
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/api-request-logs/{log_id}", response_model=dict)
- def get_api_request_log(
- log_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """API 请求日志详情"""
- from app.crud.api_request_log import api_request_log_crud
- log = api_request_log_crud.get(db, log_id)
- if not log:
- return error_response(404, "日志不存在")
- return success_response({
- "id": log.id,
- "httpMethod": log.http_method,
- "path": log.path,
- "queryString": log.query_string,
- "statusCode": log.status_code,
- "durationMs": log.duration_ms,
- "userId": log.user_id,
- "username": log.username,
- "role": log.role,
- "ipAddress": log.ip_address,
- "userAgent": log.user_agent,
- "requestBody": log.request_body,
- "responseBody": log.response_body,
- "createAt": str(log.create_at) if log.create_at else None,
- })
- @router.delete("/api-request-logs/{log_id}", response_model=dict)
- def delete_api_request_log(
- log_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """删除 API 请求日志(软删除)"""
- from app.crud.api_request_log import api_request_log_crud
- if not api_request_log_crud.delete(db, log_id):
- return error_response(404, "日志不存在")
- return success_response({"id": log_id})
- # =========================================
- # 数据统计 API
- # =========================================
- @router.get("/statistics/dashboard", response_model=dict)
- def get_dashboard_statistics(
- regionId: Optional[str] = Query(None, description="区域ID"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取仪表盘统计数据"""
- from app.crud.statistics import statistics_crud
- include_inactive_regions = current_user.get("role") == "super_admin"
- stats = statistics_crud.get_dashboard_summary(
- db,
- region_id=regionId,
- include_inactive_regions=include_inactive_regions,
- )
- return success_response(stats)
- @router.get("/statistics/orders", response_model=dict)
- def get_order_statistics(
- regionId: Optional[str] = Query(None, description="区域ID"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取订单统计数据"""
- from app.crud.statistics import statistics_crud
- stats = statistics_crud.get_order_statistics(
- db,
- region_id=regionId,
- start_date=startDate,
- end_date=endDate
- )
- return success_response(stats)
- @router.get("/statistics/orders/trend", response_model=dict)
- def get_order_trend(
- regionId: Optional[str] = Query(None, description="区域ID"),
- days: int = Query(7, ge=1, le=30, description="统计天数"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取订单趋势数据"""
- from app.crud.statistics import statistics_crud
- trend = statistics_crud.get_order_trend(
- db,
- region_id=regionId,
- days=days,
- include_inactive_regions=current_user.get("role") == "super_admin",
- )
- return success_response(trend)
- @router.get("/statistics/orders/today-by-region", response_model=dict)
- def get_today_orders_by_region(
- regionId: Optional[str] = Query(None, description="区域ID,传入时仅返回该区域"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """今日各区域营业额(元)与订单数(按区域列表,无单为 0)"""
- from app.crud.statistics import statistics_crud
- rows = statistics_crud.get_today_order_stats_by_region(
- db,
- region_id=regionId,
- include_inactive_regions=current_user.get("role") == "super_admin",
- )
- return success_response(rows)
- @router.get("/statistics/orders/today-by-client", response_model=dict)
- def get_today_orders_by_client(
- regionId: Optional[str] = Query(None, description="区域ID"),
- limit: int = Query(30, ge=1, le=100, description="最多返回客户条数"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """今日下单客户:下单金额(元)、商品数量合计(按客户,金额降序)"""
- from app.crud.statistics import statistics_crud
- rows = statistics_crud.get_today_order_stats_by_client(
- db,
- region_id=regionId,
- limit=limit,
- include_inactive_regions=current_user.get("role") == "super_admin",
- )
- return success_response(rows)
- @router.get("/statistics/financial", response_model=dict)
- def get_financial_statistics(
- regionId: Optional[str] = Query(None, description="区域ID"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取财务统计数据"""
- from app.crud.statistics import statistics_crud
- stats = statistics_crud.get_financial_statistics(
- db,
- region_id=regionId,
- start_date=startDate,
- end_date=endDate
- )
- return success_response(stats)
- @router.get("/statistics/financial/trend", response_model=dict)
- def get_financial_trend(
- regionId: Optional[str] = Query(None, description="区域ID"),
- days: int = Query(7, ge=1, le=30, description="统计天数"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取财务趋势数据"""
- from app.crud.statistics import statistics_crud
- trend = statistics_crud.get_financial_trend(
- db,
- region_id=regionId,
- days=days
- )
- return success_response(trend)
- @router.get("/statistics/delivery", response_model=dict)
- def get_delivery_statistics(
- regionId: Optional[str] = Query(None, description="区域ID"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取配送统计数据"""
- from app.crud.statistics import statistics_crud
- stats = statistics_crud.get_delivery_statistics(
- db,
- region_id=regionId,
- start_date=startDate,
- end_date=endDate
- )
- return success_response(stats)
- @router.get("/statistics/products", response_model=dict)
- def get_product_statistics(
- regionId: Optional[str] = Query(None, description="区域ID"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- limit: int = Query(10, ge=1, le=50, description="返回数量"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取商品销售排行"""
- from app.crud.statistics import statistics_crud
- stats = statistics_crud.get_product_statistics(
- db,
- region_id=regionId,
- start_date=startDate,
- end_date=endDate,
- limit=limit
- )
- return success_response(stats)
- @router.get("/statistics/customers", response_model=dict)
- def get_customer_statistics(
- regionId: Optional[str] = Query(None, description="区域ID"),
- startDate: Optional[str] = Query(None, description="开始日期"),
- endDate: Optional[str] = Query(None, description="结束日期"),
- limit: int = Query(10, ge=1, le=50, description="返回数量"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取客户订单排行"""
- from app.crud.statistics import statistics_crud
- stats = statistics_crud.get_customer_statistics(
- db,
- region_id=regionId,
- start_date=startDate,
- end_date=endDate,
- limit=limit
- )
- return success_response(stats)
- # =========================================
- # 缺失的订单相关 API
- # =========================================
- @router.post("/orders/{order_id}/complete", response_model=dict)
- def force_complete_order(
- order_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """完结订单"""
- from app.crud.order import order_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- # 新状态模型:待收货阶段可由运营强制完结
- if getattr(order, "order_status", None) != "pending_confirm":
- return error_response(400, "只有待收货订单才能完结")
- try:
- completed_order = order_crud.force_complete(db, order_id, current_user.get("user_id"))
- return success_response({
- "id": completed_order.id,
- "status": completed_order.status,
- "message": "订单已完结"
- })
- except Exception as e:
- return error_response(400, str(e))
- @router.post("/orders/{order_id}/cancel", response_model=dict)
- def cancel_order_admin(
- order_id: str,
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """运营端取消订单"""
- request, req_err = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if req_err is not None:
- return req_err
- from app.crud.order import order_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- # 新状态模型:仅未完成且未取消订单允许运营取消
- if getattr(order, "order_status", None) in ["completed", "cancelled"]:
- return error_response(400, "已完成或已取消订单不能再次取消")
- reason = request.get("reason", "")
- note = request.get("note", "")
- evidence_image_urls = request.get("evidenceImageUrls", []) or []
- if not reason:
- return error_response(400, "请填写取消原因")
- if not isinstance(evidence_image_urls, list):
- return error_response(400, "evidenceImageUrls 格式不正确")
- try:
- cancelled_order = order_crud.admin_cancel(
- db,
- order_id=order_id,
- operator_id=current_user.get("user_id"),
- operator_name=current_user.get("real_name"),
- reason=reason,
- note=note,
- evidence_image_urls=evidence_image_urls,
- )
- if evidence_image_urls:
- from app.models.models import BizAttachment
- from app.core.utils import generate_id
- for url in evidence_image_urls:
- if not isinstance(url, str) or not url.strip():
- continue
- db.add(BizAttachment(
- id=generate_id(),
- business_type="order",
- business_id=order_id,
- file_name=url.split("/")[-1][:200],
- file_url=url,
- file_type="image/*",
- upload_by=current_user.get("user_id"),
- upload_by_name=current_user.get("real_name"),
- create_by=current_user.get("user_id"),
- create_name=current_user.get("real_name"),
- ))
- db.commit()
- return success_response({
- "id": cancelled_order.id,
- "status": cancelled_order.status,
- "message": "订单已取消"
- })
- except Exception as e:
- return error_response(400, str(e))
- @router.get("/orders/adjustable", response_model=dict)
- def list_adjustable_orders(
- keyword: Optional[str] = Query(None, description="关键词搜索"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取可调整的订单列表"""
- from app.crud.order import order_crud, order_item_crud
- # 可调整的订单状态:待支付、待收货(线下单)
- adjustable_statuses = ["pending_payment", "pending_confirm"]
- orders, total = order_crud.get_multi_with_filters(
- db,
- status=None, # 不按状态过滤,我们手动过滤
- order_no=keyword,
- skip=0,
- limit=100 # 默认获取100条
- )
- # 过滤出可调整的订单
- adjustable_orders = []
- for order in orders:
- if getattr(order, "order_status", None) in adjustable_statuses and order_crud.normalize_payment_method(order.payment_method) == "offline":
- item_count = order_item_crud.get_count_by_order(db, order.id)
- adjustable_orders.append({
- "id": order.id,
- "orderNo": order.order_no,
- "status": order.status,
- "orderStatus": getattr(order, "order_status", None) or "pending_confirm",
- "deliveryStatus": getattr(order, "delivery_status", None) or "pending_dispatch",
- "payStatus": getattr(order, "pay_status", None) or "pending_payment",
- "statusText": ORDER_STATUS_MAP.get(order.status, order.status),
- "totalAmount": float(order.total_amount) if order.total_amount else 0,
- "itemCount": item_count,
- "createAt": str(order.create_at) if order.create_at else None
- })
- return success_response({
- "list": adjustable_orders,
- "total": len(adjustable_orders)
- })
- @router.get("/orders/{order_id}/items", response_model=dict)
- def get_order_items(
- order_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取订单商品列表"""
- from app.crud.order import order_crud, order_item_crud
- order = order_crud.get(db, order_id)
- if not order:
- return error_response(404, "订单不存在")
- items = order_item_crud.get_by_order(db, order_id)
- from app.crud.price_list import price_list_crud
- price_list = price_list_crud.get(db, order.price_list_id) if order.price_list_id else None
- supplier_id_from_price_list = getattr(price_list, "supplier_id", None) if price_list else None
- supplier_name_from_price_list = None
- if supplier_id_from_price_list:
- _sup_u = user_crud.get(db, supplier_id_from_price_list)
- if _sup_u:
- supplier_name_from_price_list = _sup_u.real_name or _sup_u.username
- pl_ids_for_order = [order.price_list_id] if order.price_list_id else []
- pli_map_for_order = _price_list_item_map(db, pl_ids_for_order)
- from app.crud.delivery import delivery_task_item_crud as _merge_task_delivered_items
- _line_delivered_from_task = _merge_task_delivered_items.get_delivered_quantity_by_order_item(db, order_id)
- result = []
- for item in items:
- product = product_crud.get(db, item.product_id)
- unit_name = None
- if item.unit_id:
- u = unit_crud.get(db, item.unit_id)
- unit_name = u.name if u else None
- category_name = None
- if product and product.category_id:
- cat = category_crud.get(db, product.category_id)
- category_name = cat.name if cat else None
- _oi_dq = float(item.delivered_quantity) if item.delivered_quantity else 0.0
- _ti_dq = float(_line_delivered_from_task.get(item.id, 0.0))
- _merged_dq = max(_oi_dq, _ti_dq)
- qty = float(item.quantity) if item.quantity else 0
- selling_u = float(item.unit_price) if item.unit_price else 0
- selling_amt = float(item.amount) if item.amount else round(qty * selling_u, 2)
- pli = pli_map_for_order.get((order.price_list_id, item.product_id, item.unit_id))
- supplier_u = _supplier_unit_yuan_from_pli(pli)
- if supplier_u is None:
- supplier_u = selling_u
- supplier_amt = round(qty * supplier_u, 2)
- result.append({
- "id": item.id,
- "productId": item.product_id,
- "productName": product.name if product else None,
- "imageUrl": getattr(product, "image_url", None) if product else None,
- "categoryName": category_name,
- "unitId": item.unit_id,
- "unitName": unit_name,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
- "supplierId": supplier_id_from_price_list,
- "supplierName": supplier_name_from_price_list,
- "quantity": qty,
- "unitPrice": selling_u,
- "amount": selling_amt,
- "sellingUnitPrice": selling_u,
- "sellingAmount": selling_amt,
- "supplierUnitPrice": supplier_u,
- "supplierAmount": supplier_amt,
- "deliveredQuantity": _merged_dq,
- "status": item.status
- })
- return success_response({
- "list": result,
- "total": len(result)
- })
- # =========================================
- # 商品简单列表 API
- # =========================================
- @router.get("/products/simple", response_model=dict)
- def list_products_simple(
- keyword: Optional[str] = Query(None, description="关键词搜索"),
- categoryId: Optional[str] = Query(None, description="分类ID"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取商品简单列表(用于订单调整选择替代商品)"""
- from app.models.models import BizProduct
- query = db.query(BizProduct).filter(BizProduct.is_del == 0, BizProduct.is_active == 1)
- if keyword:
- query = query.filter(BizProduct.name.ilike(f"%{keyword}%"))
- if categoryId:
- query = query.filter(BizProduct.category_id == categoryId)
- products = query.order_by(BizProduct.name).limit(100).all()
- result = []
- for p in products:
- category_name = None
- if p.category_id:
- cat = category_crud.get(db, p.category_id)
- category_name = cat.name if cat else None
- unit_name = None
- if p.unit_id:
- u = unit_crud.get(db, p.unit_id)
- unit_name = u.name if u else None
- result.append({
- "id": p.id,
- "name": p.name,
- "categoryId": p.category_id,
- "categoryName": category_name,
- "unitId": p.unit_id,
- "unitName": unit_name,
- "imageUrl": p.image_url
- })
- return success_response({
- "list": result,
- "total": len(result)
- })
- # =========================================
- # 询价单简单列表 API
- # =========================================
- @router.get("/inquiries/simple", response_model=dict)
- def list_inquiries_simple(
- status: Optional[str] = Query(None, description="状态"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取询价单简单列表(用于报价对比选择)"""
- from app.models.models import BizInquiry
- query = db.query(BizInquiry).filter(BizInquiry.is_del == 0)
- if status:
- query = query.filter(BizInquiry.status == status)
- inquiries = query.order_by(BizInquiry.create_at.desc()).limit(50).all()
- result = []
- for inquiry in inquiries:
- region_name = None
- if inquiry.region_id:
- region = region_crud.get(db, inquiry.region_id)
- region_name = region.name if region else None
- result.append({
- "id": inquiry.id,
- "title": inquiry.title,
- "status": inquiry.status,
- "regionId": inquiry.region_id,
- "regionName": region_name,
- "createAt": str(inquiry.create_at) if inquiry.create_at else None
- })
- return success_response({
- "list": result,
- "total": len(result)
- })
- # =========================================
- # 报价确认供应商 API
- # =========================================
- @router.post("/quotations/confirm-supplier", response_model=dict)
- def confirm_quotation_supplier(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """确认选择供应商报价,并生成区域生效价格清单(active)
- 约束:
- - 同一询价单,仅允许 1 个报价单为 selected
- - 报价单状态仅使用:submitted / selected
- - 不改变询价单状态(仍为 published 等业务状态)
- """
- from app.models.models import BizInquiry, BizQuotation, BizPriceList, BizPriceListItem
- from app.crud.quotation import quotation_crud, quotation_item_crud
- from app.crud.inquiry import inquiry_item_crud
- from app.crud.product import product_crud
- from app.core.utils import generate_id
- from sqlalchemy import and_
- from decimal import Decimal
- payload, payload_error = _resolve_admin_payload_with_crypto_fallback(request, current_user)
- if payload_error:
- return payload_error
- inquiry_id = payload.get("inquiryId")
- quotation_id = payload.get("quotationId")
- final_price_items = payload.get("finalPriceItems") or []
- if not inquiry_id or not quotation_id:
- return error_response(400, "询价单ID和报价ID不能为空")
- inquiry = db.query(BizInquiry).filter(and_(BizInquiry.id == inquiry_id, BizInquiry.is_del == 0)).first()
- if not inquiry:
- return error_response(404, "询价单不存在")
- quotation = quotation_crud.get(db, quotation_id)
- if not quotation:
- return error_response(404, "报价不存在")
- if quotation.inquiry_id != inquiry_id:
- return error_response(400, "报价不属于该询价单")
- # 询价单必须处于发布中,才允许选择供应商生成生效价格
- if inquiry.status != "published":
- return error_response(400, "只能对已发布状态的询价单选择供应商报价")
- # 同一询价仅允许一个 selected:先将其它报价恢复为 submitted
- db.query(BizQuotation).filter(
- and_(
- BizQuotation.inquiry_id == inquiry_id,
- BizQuotation.id != quotation_id,
- BizQuotation.is_del == 0
- )
- ).update({"status": "submitted"}, synchronize_session=False)
- # 当前报价置为 selected
- quotation.status = "selected"
- # 生成价格清单:范围仅询价单的商品
- inquiry_items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
- inquiry_product_ids = {i.product_id for i in inquiry_items if i and i.product_id}
- if not inquiry_product_ids:
- return error_response(400, "询价单必须包含至少一个商品")
- quotation_items = quotation_item_crud.get_by_quotation(db, quotation_id)
- quotation_item_by_pid: dict[str, any] = {}
- for qi in quotation_items or []:
- pid = str(getattr(qi, "product_id", "") or "")
- if not pid:
- continue
- if pid in inquiry_product_ids:
- quotation_item_by_pid[pid] = qi
- # 允许供应商某些商品未报价:只要运营端传入了最终售价(finalPriceItems)即可生成价格清单
- if not quotation_item_by_pid and not final_price_items:
- return error_response(400, "该报价单未包含本询价单商品报价,且未传入最终售价,无法生成价格清单")
- # 计算版本号(区域维度自增)
- max_version_row = db.query(BizPriceList).filter(
- and_(BizPriceList.region_id == inquiry.region_id, BizPriceList.is_del == 0)
- ).order_by(BizPriceList.version.desc()).first()
- next_version = (max_version_row.version + 1) if max_version_row and max_version_row.version else 1
- # 将该区域其他 active 价格清单置为 inactive
- db.query(BizPriceList).filter(
- and_(
- BizPriceList.region_id == inquiry.region_id,
- BizPriceList.status == "active",
- BizPriceList.is_del == 0
- )
- ).update({"status": "inactive"}, synchronize_session=False)
- # 创建新价格清单(直接生效)
- price_list_id = generate_id()
- now = datetime.now()
- price_list = BizPriceList(
- id=price_list_id,
- region_id=inquiry.region_id,
- supplier_id=quotation.supplier_id,
- inquiry_id=inquiry_id,
- quotation_id=quotation_id,
- source_type="quotation_select",
- version=next_version,
- status="active",
- effective_time=now,
- published_by=current_user.get("user_id"),
- published_time=now,
- remark=f"由询价单选择供应商报价生成(询价={inquiry_id}, 报价={quotation_id})",
- create_by=current_user.get("user_id"),
- create_name=current_user.get("real_name")
- )
- db.add(price_list)
- db.flush()
- # 可选:前端传入最终售卖价 + 本次调价系数(用于回显与二次发布)
- # key 统一使用 str(product_id),避免类型不一致导致匹配失败
- final_price_map: dict[str, Decimal] = {}
- pricing_coeff_map: dict[str, Decimal] = {}
- if final_price_items:
- if not isinstance(final_price_items, list):
- return error_response(400, "finalPriceItems 参数格式错误")
- for it in final_price_items:
- if not isinstance(it, dict):
- return error_response(400, "finalPriceItems 参数格式错误")
- pid = it.get("productId")
- price = it.get("price")
- pricing_coeff = it.get("pricingCoefficient")
- if not pid:
- return error_response(400, "finalPriceItems.productId 不能为空")
- pid_key = str(pid)
- try:
- price_decimal = Decimal(str(price))
- except Exception:
- return error_response(400, f"商品 {pid} 的价格格式不正确")
- if price_decimal <= Decimal("0"):
- return error_response(400, f"商品 {pid} 的价格必须大于0")
- final_price_map[pid_key] = price_decimal.quantize(Decimal("0.01"))
- if pricing_coeff is not None and pricing_coeff != "":
- try:
- coeff_decimal = Decimal(str(pricing_coeff))
- except Exception:
- return error_response(400, f"商品 {pid} 的调价系数格式不正确")
- pricing_coeff_map[pid_key] = coeff_decimal.quantize(Decimal("0.01"))
- # 写入价格清单明细:范围=询价单的商品(不要求供应商都报价)
- for ii in inquiry_items:
- pid_key = str(getattr(ii, "product_id", "") or "")
- if not pid_key:
- continue
- qi = quotation_item_by_pid.get(pid_key)
- final_price = final_price_map.get(pid_key)
- pricing_coeff = pricing_coeff_map.get(pid_key)
- # 最终售价优先取前端传入;否则回退为供应商报价
- if final_price is None:
- if qi is not None and getattr(qi, "price", None) is not None:
- final_price = Decimal(str(qi.price)).quantize(Decimal("0.01"))
- else:
- return error_response(400, f"商品 {pid_key} 缺少供应商报价,且未传入最终售价,无法生成价格清单")
- if final_price <= Decimal("0"):
- return error_response(400, f"商品 {pid_key} 的价格必须大于0")
- # 单位ID:优先报价单项;否则从商品表取
- unit_id = getattr(qi, "unit_id", None) if qi is not None else None
- if not unit_id:
- product_for_unit = product_crud.get(db, pid_key)
- unit_id = getattr(product_for_unit, "unit_id", None) if product_for_unit else None
- if not unit_id:
- return error_response(400, f"商品 {pid_key} 缺少单位信息,无法生成价格清单")
- # 若前端未传调价系数:尝试通过(最终价, 供应商报价)反推;再兜底商品默认系数
- if pricing_coeff is None:
- if qi is not None and getattr(qi, "price", None) is not None:
- try:
- supplier_price_decimal = Decimal(str(qi.price))
- except Exception:
- supplier_price_decimal = Decimal("0")
- if supplier_price_decimal > Decimal("0"):
- try:
- pricing_coeff = ((final_price / supplier_price_decimal) - Decimal("1")) * Decimal("100")
- pricing_coeff = pricing_coeff.quantize(Decimal("0.01"))
- except Exception:
- pricing_coeff = None
- if pricing_coeff is None:
- product = product_crud.get(db, pid_key)
- raw_default = getattr(product, "price_coefficient", None) if product else None
- try:
- pricing_coeff = Decimal(str(raw_default)).quantize(Decimal("0.01")) if raw_default is not None else Decimal("0.00")
- except Exception:
- pricing_coeff = Decimal("0.00")
- pli = BizPriceListItem(
- id=generate_id(),
- price_list_id=price_list_id,
- product_id=pid_key,
- unit_id=unit_id,
- price=final_price,
- pricing_coefficient=pricing_coeff,
- create_by=current_user.get("user_id"),
- create_name=current_user.get("real_name")
- )
- db.add(pli)
- db.commit()
- # 广播:该区域生效价格已更新(小程序端可实时刷新)
- try:
- from app.ws.price_updates import notify_price_list_updated, PriceListUpdatedEvent
- logger.warning(
- "[ws-mini] trigger confirm_supplier region=%s price_list_id=%s version=%s",
- inquiry.region_id,
- price_list_id,
- next_version,
- )
- notify_price_list_updated(PriceListUpdatedEvent(
- regionId=inquiry.region_id,
- priceListId=price_list_id,
- priceVersion=next_version,
- publishedTime=str(now),
- ))
- except Exception:
- # 推送失败不影响主流程
- logger.exception(
- "[ws-mini] notify failed in confirm_supplier inquiry_id=%s quotation_id=%s",
- inquiry_id,
- quotation_id,
- )
- return success_response({
- "message": "已选择供应商报价并生成生效价格",
- "inquiryId": inquiry_id,
- "quotationId": quotation_id,
- "priceListId": price_list_id,
- "priceVersion": next_version
- })
- # =========================================
- # 价格版本管理 API
- # =========================================
- @router.get("/price-versions", response_model=dict)
- def list_price_versions(
- page: int = Query(1, ge=1, description="页码"),
- pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
- regionId: Optional[str] = Query(None, description="区域ID"),
- supplierId: Optional[str] = Query(None, description="供应商ID"),
- status: Optional[str] = Query(None, description="状态"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取价格版本列表"""
- from app.models.models import BizPriceList, SysRegion, SysUser
- from sqlalchemy import func
- query = db.query(BizPriceList).filter(BizPriceList.is_del == 0)
- if regionId:
- query = query.filter(BizPriceList.region_id == regionId)
- if supplierId:
- query = query.filter(BizPriceList.supplier_id == supplierId)
- if status:
- query = query.filter(BizPriceList.status == status)
- total = int(query.with_entities(func.count(BizPriceList.id)).order_by(None).scalar() or 0)
- versions = (
- query
- .with_entities(
- BizPriceList.id,
- BizPriceList.version,
- BizPriceList.region_id,
- BizPriceList.supplier_id,
- BizPriceList.status,
- BizPriceList.effective_time,
- BizPriceList.remark,
- BizPriceList.create_at,
- )
- .order_by(BizPriceList.create_at.desc())
- .offset((page - 1) * pageSize)
- .limit(pageSize)
- .all()
- )
- region_ids = list({v.region_id for v in versions if getattr(v, "region_id", None)})
- supplier_ids = list({v.supplier_id for v in versions if getattr(v, "supplier_id", None)})
- region_map = {}
- if region_ids:
- regions = db.query(SysRegion.id, SysRegion.name).filter(SysRegion.id.in_(region_ids), SysRegion.is_del == 0).all()
- region_map = {r.id: r.name for r in regions}
- supplier_map = {}
- if supplier_ids:
- suppliers = db.query(SysUser.id, SysUser.real_name).filter(SysUser.id.in_(supplier_ids), SysUser.is_del == 0).all()
- supplier_map = {u.id: u.real_name for u in suppliers}
- result = []
- for v in versions:
- result.append({
- "id": v.id,
- "versionNo": getattr(v, "version_no", None) or getattr(v, "version", None),
- "regionId": v.region_id,
- "regionName": region_map.get(v.region_id),
- "supplierId": v.supplier_id,
- "supplierName": supplier_map.get(v.supplier_id),
- "status": v.status,
- "effectiveTime": str(v.effective_time) if v.effective_time else None,
- "remark": v.remark,
- "createAt": str(v.create_at) if v.create_at else None
- })
- return success_response({
- "list": result,
- "total": total,
- "page": page,
- "pageSize": pageSize,
- "totalPages": (total + pageSize - 1) // pageSize
- })
- @router.get("/price-versions/{version_id}", response_model=dict)
- def get_price_version_detail(
- version_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """获取价格版本详情(含供应商报价与最终售价拆解,便于订单侧只读查看)。"""
- from app.models.models import BizPriceList, BizPriceListItem, BizQuotationItem
- from app.crud.inquiry import inquiry_crud
- version = db.query(BizPriceList).filter(BizPriceList.id == version_id, BizPriceList.is_del == 0).first()
- if not version:
- return error_response(404, "价格清单不存在")
- region_name = None
- if version.region_id:
- region = region_crud.get(db, version.region_id)
- region_name = region.name if region else None
- supplier_name = None
- if version.supplier_id:
- supplier = user_crud.get(db, version.supplier_id)
- supplier_name = supplier.real_name if supplier else None
- inquiry_title = None
- if version.inquiry_id:
- iq = inquiry_crud.get(db, version.inquiry_id)
- inquiry_title = iq.title if iq else None
- # 来源报价单:用于还原供应商侧单价
- q_item_by_pid: dict[str, object] = {}
- if version.quotation_id:
- q_rows = db.query(BizQuotationItem).filter(
- and_(
- BizQuotationItem.quotation_id == version.quotation_id,
- BizQuotationItem.is_del == 0
- )
- ).all()
- for qi in q_rows:
- pid = str(getattr(qi, "product_id", "") or "")
- if pid and pid not in q_item_by_pid:
- q_item_by_pid[pid] = qi
- # 获取版本明细
- items = db.query(BizPriceListItem).filter(
- BizPriceListItem.price_list_id == version_id,
- BizPriceListItem.is_del == 0
- ).all()
- item_details = []
- for item in items:
- product = product_crud.get(db, item.product_id)
- unit_name = None
- if item.unit_id:
- u = unit_crud.get(db, item.unit_id)
- unit_name = u.name if u else None
- pid = str(item.product_id or "")
- q_item = q_item_by_pid.get(pid)
- supplier_price = float(getattr(q_item, "price", 0) or 0) if q_item else 0.0
- coeff = None
- if getattr(item, "pricing_coefficient", None) is not None:
- coeff = float(item.pricing_coefficient)
- elif product and getattr(product, "price_coefficient", None) is not None:
- coeff = float(product.price_coefficient)
- else:
- coeff = 0.0
- final_price = float(item.price) if item.price else 0.0
- category_id = getattr(product, "category_id", None) if product else None
- category_path = _category_path_display(db, str(category_id)) if category_id else ""
- item_details.append({
- "id": item.id,
- "productId": item.product_id,
- "productName": product.name if product else None,
- "imageUrl": getattr(product, "image_url", None) if product else None,
- "categoryId": str(category_id) if category_id else None,
- "categoryName": category_path or None,
- "measureValue": _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None,
- "unitId": item.unit_id,
- "unitName": unit_name,
- "supplierPrice": supplier_price,
- "pricingCoefficient": coeff,
- "finalPrice": final_price,
- "price": final_price
- })
- return success_response({
- "id": version.id,
- "versionNo": getattr(version, "version_no", None) or getattr(version, "version", None),
- "version": getattr(version, "version", None),
- "regionId": version.region_id,
- "regionName": region_name,
- "supplierId": version.supplier_id,
- "supplierName": supplier_name,
- "inquiryId": version.inquiry_id,
- "inquiryTitle": inquiry_title,
- "quotationId": version.quotation_id,
- "status": version.status,
- "effectiveTime": str(version.effective_time) if version.effective_time else None,
- "remark": version.remark,
- "createAt": str(version.create_at) if version.create_at else None,
- "items": item_details
- })
- @router.post("/price-versions", response_model=dict)
- def create_price_version(
- request: dict,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """创建价格版本"""
- from app.models.models import BizPriceList
- from app.core.utils import generate_uuid
- region_id = request.get("regionId")
- supplier_id = request.get("supplierId")
- effective_time = request.get("effectiveTime")
- remark = request.get("remark")
- if not region_id or not supplier_id:
- return error_response(400, "区域ID和供应商ID不能为空")
- # 生成版本号
- today = datetime.now().strftime("%Y%m%d")
- count = db.query(BizPriceList).filter(
- BizPriceList.region_id == region_id,
- BizPriceList.supplier_id == supplier_id
- ).count()
- version_no = f"PL{today}{count + 1:04d}"
- # 解析生效时间
- effective_dt = None
- if effective_time:
- try:
- effective_dt = datetime.strptime(effective_time, "%Y-%m-%d %H:%M:%S")
- except:
- try:
- effective_dt = datetime.strptime(effective_time, "%Y-%m-%d")
- except:
- pass
- new_version = BizPriceList(
- id=generate_uuid(),
- region_id=region_id,
- supplier_id=supplier_id,
- version_no=version_no,
- status="draft",
- effective_time=effective_dt,
- remark=remark,
- create_at=datetime.now(),
- create_by=current_user.get("user_id"),
- create_name=current_user.get("real_name"),
- is_del=0
- )
- db.add(new_version)
- db.commit()
- return success_response({
- "id": new_version.id,
- "versionNo": version_no,
- "message": "价格版本创建成功"
- })
- @router.post("/price-versions/{version_id}/publish", response_model=dict)
- def publish_price_version(
- version_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """发布价格版本"""
- from app.models.models import BizPriceList
- version = db.query(BizPriceList).filter(BizPriceList.id == version_id, BizPriceList.is_del == 0).first()
- if not version:
- return error_response(404, "价格版本不存在")
- if version.status != "draft":
- return error_response(400, "只有草稿状态的价格版本才能发布")
- version.status = "published"
- version.publish_time = datetime.now()
- version.publisher_id = current_user.get("user_id")
- db.commit()
- # 广播:价格版本发布后,通知该区域客户端刷新价格
- try:
- from app.ws.price_updates import notify_price_list_updated, PriceListUpdatedEvent
- notify_price_list_updated(PriceListUpdatedEvent(
- regionId=str(getattr(version, "region_id", "") or ""),
- priceListId=str(getattr(version, "id", "") or ""),
- priceVersion=int(getattr(version, "version", 0) or 0),
- publishedTime=str(getattr(version, "publish_time", None) or ""),
- ))
- except Exception:
- # 推送失败不影响主流程
- logger.exception(
- "[ws-mini] notify failed in publish_price_version version_id=%s",
- version_id,
- )
- return success_response({
- "id": version.id,
- "status": version.status,
- "message": "价格版本发布成功"
- })
- @router.post("/price-versions/{version_id}/disable", response_model=dict)
- def disable_price_version(
- version_id: str,
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """禁用价格版本"""
- from app.models.models import BizPriceList
- version = db.query(BizPriceList).filter(BizPriceList.id == version_id, BizPriceList.is_del == 0).first()
- if not version:
- return error_response(404, "价格版本不存在")
- if version.status == "invalid":
- return error_response(400, "该价格版本已经失效")
- version.status = "invalid"
- version.invalidate_time = datetime.now()
- version.invalidate_operator_id = current_user.get("user_id")
- db.commit()
- return success_response({
- "id": version.id,
- "status": version.status,
- "message": "价格版本已禁用"
- })
- @router.get("/price-versions/compare", response_model=dict)
- def compare_price_versions(
- versionIds: str = Query(..., description="版本ID列表,逗号分隔"),
- db: Session = Depends(get_db),
- current_user: dict = Depends(require_admin)
- ):
- """版本价格对比"""
- from app.models.models import BizPriceList, BizPriceListItem
- version_id_list = [v.strip() for v in versionIds.split(",") if v.strip()]
- if len(version_id_list) < 2:
- return error_response(400, "至少需要选择两个版本进行对比")
- versions = db.query(BizPriceList).filter(
- BizPriceList.id.in_(version_id_list),
- BizPriceList.is_del == 0
- ).all()
- if len(versions) < 2:
- return error_response(400, "选择的版本中有不存在的")
- # 获取所有版本的价格明细
- version_items = {}
- all_products = set()
- for version in versions:
- items = db.query(BizPriceListItem).filter(
- BizPriceListItem.price_list_id == version.id,
- BizPriceListItem.is_del == 0
- ).all()
- version_items[version.id] = {item.product_id: float(item.price) if item.price else 0 for item in items}
- all_products.update([item.product_id for item in items])
- # 构建对比数据
- compare_data = []
- for product_id in all_products:
- product = product_crud.get(db, product_id)
- if not product:
- continue
- row = {
- "productId": product_id,
- "productName": product.name,
- "prices": {}
- }
- for version in versions:
- row["prices"][version.id] = version_items.get(version.id, {}).get(product_id, None)
- if row["prices"][version.id] is not None:
- if not hasattr(row, "lowestPrice") or row["lowestPrice"] > row["prices"][version.id]:
- row["lowestPrice"] = row["prices"][version.id]
- row["lowestVersionId"] = version.id
- compare_data.append(row)
- # 版本基本信息
- version_info = [{
- "id": v.id,
- "versionNo": v.version_no,
- "status": v.status
- } for v in versions]
- return success_response({
- "versions": version_info,
- "products": compare_data
- })
|