ContractFormDialog.vue 116 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523
  1. <template>
  2. <el-dialog
  3. v-model="dialogVisible"
  4. :title="isPageVariant ? undefined : formTitle"
  5. :width="isPageVariant ? '100%' : 'calc(100vw - 48px)'"
  6. :fullscreen="isPageVariant"
  7. :show-close="!isPageVariant"
  8. :modal="!isPageVariant"
  9. :append-to-body="!isPageVariant"
  10. align-center
  11. :class="[
  12. 'dialog-form-layout',
  13. 'contract-form-dialog',
  14. { 'contract-form-dialog--page': isPageVariant }
  15. ]"
  16. destroy-on-close
  17. :modal-class="isPageVariant ? undefined : 'order-module-dialog-mask'"
  18. :close-on-click-modal="false"
  19. @opened="handleOpened"
  20. >
  21. <div
  22. class="contract-form-body"
  23. v-loading="formLoading"
  24. element-loading-text="正在加载合同数据..."
  25. >
  26. <el-form
  27. ref="formRef"
  28. :model="form"
  29. :rules="rules"
  30. :label-position="isPageVariant ? 'top' : 'right'"
  31. :label-width="isPageVariant ? undefined : '120px'"
  32. class="crm-dialog-form"
  33. :class="{ 'crm-dialog-form--page': isPageVariant }"
  34. :disabled="formLoading"
  35. >
  36. <!-- 合同基础信息(一行 4 列) -->
  37. <div class="contract-basic-info">
  38. <el-form-item label="合同编号" prop="contractNo">
  39. <el-input v-model="form.contractNo" placeholder="系统自动生成,格式:SX/X-JT20250926002" maxlength="50" />
  40. </el-form-item>
  41. <el-form-item label="合同名称" prop="contractName">
  42. <el-input
  43. v-model="form.contractName"
  44. placeholder="可不填,自动生成:供方简称-需方简称(合同编号)"
  45. maxlength="100"
  46. show-word-limit
  47. />
  48. </el-form-item>
  49. <el-form-item label="合同类型" prop="contractType" required>
  50. <el-select v-model="form.contractType" placeholder="请选择合同类型" style="width: 100%" @change="handleContractTypeChange">
  51. <el-option v-for="dict in dictStore.getOptions('crm_contract_type')" :key="dict.value" :label="dict.label" :value="dict.value" />
  52. </el-select>
  53. </el-form-item>
  54. <el-form-item label="合同模版" prop="contractTemplate" required>
  55. <el-select v-model="form.contractTemplate" placeholder="请选择合同模版" style="width: 100%" @change="handleContractTemplateChange">
  56. <el-option v-for="dict in dictStore.getOptions('crm_contract_template')" :key="dict.value" :label="dict.label" :value="dict.value" />
  57. </el-select>
  58. </el-form-item>
  59. <el-form-item label="客户名称" prop="customerId" required>
  60. <div class="customer-select-row">
  61. <el-select
  62. v-model="form.customerId"
  63. class="customer-select-row__select"
  64. filterable
  65. remote
  66. clearable
  67. :remote-method="searchCustomerOptions"
  68. :loading="customerOptionsLoading"
  69. placeholder="请输入客户名称/拼音首字母搜索"
  70. @focus="handleCustomerSelectFocus"
  71. @change="handleCustomerSelectChange"
  72. >
  73. <el-option
  74. v-for="item in customerOptions"
  75. :key="item.id"
  76. :label="item.customerName"
  77. :value="item.id"
  78. >
  79. <div class="customer-option-row">
  80. <span>{{ item.customerName }}</span>
  81. <span v-if="item.customerNo" class="customer-option-row__meta">{{ item.customerNo }}</span>
  82. </div>
  83. </el-option>
  84. </el-select>
  85. <el-button text class="customer-select-row__search" @click="handleSelectCustomer">
  86. <el-icon><Search /></el-icon>
  87. </el-button>
  88. </div>
  89. </el-form-item>
  90. <el-form-item label="关联报价单" prop="quoteId">
  91. <el-input v-model="form.quoteNo" placeholder="请选择报价单(可选)" readonly clearable>
  92. <template #append>
  93. <el-button text @click="handleSelectQuote">
  94. <el-icon><Search /></el-icon>
  95. </el-button>
  96. </template>
  97. </el-input>
  98. </el-form-item>
  99. <el-form-item label="联系人" prop="contactId" required>
  100. <div class="customer-select-row">
  101. <el-select
  102. v-model="form.contactId"
  103. class="customer-select-row__select"
  104. filterable
  105. clearable
  106. :disabled="!form.customerId"
  107. :loading="contactOptionsLoading"
  108. :placeholder="form.customerId ? '请选择联系人' : '请先选择客户'"
  109. @visible-change="handleContactSelectVisible"
  110. @change="handleContactSelectChange"
  111. >
  112. <el-option
  113. v-for="item in contactOptions"
  114. :key="item.id"
  115. :label="item.contactName"
  116. :value="item.id"
  117. >
  118. <div class="customer-option-row">
  119. <span>
  120. {{ item.contactName }}
  121. <el-tag v-if="item.isPrimary === 1" type="success" size="small" style="margin-left: 6px">主要</el-tag>
  122. </span>
  123. <span v-if="item.mobile" class="customer-option-row__meta">{{ item.mobile }}</span>
  124. </div>
  125. </el-option>
  126. </el-select>
  127. <el-button
  128. text
  129. class="customer-select-row__search"
  130. :disabled="!form.customerId"
  131. @click="handleSelectContact"
  132. >
  133. <el-icon><Search /></el-icon>
  134. </el-button>
  135. </div>
  136. </el-form-item>
  137. <el-form-item label="联系电话" prop="contactsPhone">
  138. <el-input v-model="form.contactsPhone" placeholder="从客户/联系人带出" maxlength="20" />
  139. </el-form-item>
  140. <el-form-item label="签订日期" prop="signDate">
  141. <el-date-picker
  142. v-model="form.signDate"
  143. type="date"
  144. placeholder="选择签订日期"
  145. value-format="YYYY-MM-DD"
  146. style="width: 100%"
  147. @change="handleSignDateChange"
  148. />
  149. </el-form-item>
  150. <el-form-item label="负责人" prop="ownerUserId" required>
  151. <el-select
  152. v-model="form.ownerUserId"
  153. placeholder="请选择负责人"
  154. filterable
  155. clearable
  156. style="width: 100%"
  157. @change="handleOwnerChange"
  158. >
  159. <el-option
  160. v-for="user in ownerUserList"
  161. :key="user.userId"
  162. :label="user.nickName"
  163. :value="user.userId"
  164. />
  165. </el-select>
  166. </el-form-item>
  167. <el-form-item label="合同金额(元)" prop="amount">
  168. <el-input-number
  169. v-model="form.amount"
  170. :precision="2"
  171. :min="0"
  172. placeholder="可选填,留空则从产品明细自动计算"
  173. style="width: 100%"
  174. class="money-field-input"
  175. />
  176. </el-form-item>
  177. <el-form-item label="运费(元)" prop="freight">
  178. <el-input-number
  179. v-model="form.freight"
  180. :precision="2"
  181. :min="0"
  182. placeholder="可选,选择报价单后可自动带入"
  183. style="width: 100%"
  184. class="money-field-input"
  185. />
  186. </el-form-item>
  187. <el-form-item label="桶押金(元)" prop="barrelDeposit">
  188. <el-input-number
  189. v-model="form.barrelDeposit"
  190. :precision="2"
  191. :min="0"
  192. placeholder="可选,选择报价单后可自动带入"
  193. style="width: 100%"
  194. class="money-field-input"
  195. />
  196. </el-form-item>
  197. <el-form-item label="合同期限" prop="startDate" required>
  198. <el-date-picker
  199. v-model="contractDateRange"
  200. type="daterange"
  201. range-separator="-"
  202. start-placeholder="开始日期"
  203. end-placeholder="结束日期"
  204. value-format="YYYY-MM-DD"
  205. style="width: 100%"
  206. @change="handleDateRangeChange"
  207. />
  208. </el-form-item>
  209. <el-form-item label="交易主体" prop="supplierId" required>
  210. <el-select v-model="form.supplierId" placeholder="请选择交易主体" clearable style="width: 100%" @change="handleSupplierChange">
  211. <el-option v-for="supplier in supplierList" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" />
  212. </el-select>
  213. </el-form-item>
  214. <el-form-item label="结算方式" prop="settlementMethod" required>
  215. <el-select v-model="form.settlementMethod" placeholder="请选择结算方式" clearable style="width: 100%">
  216. <el-option v-for="dict in dictStore.getOptions('crm_settlement_method')" :key="dict.value" :label="dict.label" :value="dict.value" />
  217. </el-select>
  218. </el-form-item>
  219. </div>
  220. <!-- 供方 / 需方信息(左右布局,默认缩略、各自独立展开) -->
  221. <div class="parties-section">
  222. <div class="parties-section-toolbar">
  223. <el-button text type="primary" size="small" @click="partiesExpanded = !partiesExpanded">
  224. <el-icon>
  225. <ArrowUp v-if="partiesExpanded" />
  226. <ArrowDown v-else />
  227. </el-icon>
  228. {{ partiesExpanded ? '收起供需方' : '展开供需方' }}
  229. </el-button>
  230. <el-button type="primary" size="small" @click="handleSwapParties">
  231. <el-icon><Sort /></el-icon>
  232. 供需方调换
  233. </el-button>
  234. </div>
  235. <div class="parties-row" :key="'parties-swap-' + partiesSwapEpoch">
  236. <div class="party-panel" :class="{ 'is-collapsed': !partiesExpanded }">
  237. <div class="party-panel__header">
  238. <div class="party-panel__title">供方信息</div>
  239. </div>
  240. <div v-if="!partiesExpanded" class="party-panel__summary">
  241. <span class="party-panel__summary-label">单位名称</span>
  242. <span class="party-panel__summary-value" :class="{ 'is-empty': !form.partyA }">
  243. {{ form.partyA || '未填写' }}
  244. </span>
  245. </div>
  246. <div v-show="partiesExpanded" class="form-grid-2">
  247. <el-form-item label="单位名称(章)" prop="partyA">
  248. <el-input v-model="form.partyA" placeholder="请输入单位名称" maxlength="100" />
  249. </el-form-item>
  250. <el-form-item label="单位地址" prop="partyAAddress">
  251. <el-input v-model="form.partyAAddress" placeholder="请输入单位地址" maxlength="200" />
  252. </el-form-item>
  253. <el-form-item label="法定代表人" prop="partyALegalPerson">
  254. <el-input v-model="form.partyALegalPerson" placeholder="请输入法定代表人" maxlength="100" />
  255. </el-form-item>
  256. <el-form-item label="代理人" prop="partyAAgent">
  257. <el-input v-model="form.partyAAgent" placeholder="请输入代理人(可选)" maxlength="100" />
  258. </el-form-item>
  259. <el-form-item label="电话/传真" prop="partyAPhoneFax">
  260. <el-input v-model="form.partyAPhoneFax" placeholder="请输入电话/传真" maxlength="100" />
  261. </el-form-item>
  262. <el-form-item label="开户银行" prop="partyABank">
  263. <el-input v-model="form.partyABank" placeholder="请输入开户银行" maxlength="200" />
  264. </el-form-item>
  265. <el-form-item label="账号" prop="partyAAccount">
  266. <el-input v-model="form.partyAAccount" placeholder="请输入账号" maxlength="100" />
  267. </el-form-item>
  268. </div>
  269. </div>
  270. <div class="party-flow-bridge" aria-hidden="true">
  271. <span class="party-flow-bridge__label">供货</span>
  272. <span class="party-flow-bridge__arrow">
  273. <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
  274. <path
  275. d="M5 12h11.5"
  276. stroke="currentColor"
  277. stroke-width="2.75"
  278. stroke-linecap="round"
  279. />
  280. <path
  281. d="M13.5 8.5 18.5 12l-5 3.5"
  282. stroke="currentColor"
  283. stroke-width="2.75"
  284. stroke-linecap="round"
  285. stroke-linejoin="round"
  286. />
  287. </svg>
  288. </span>
  289. </div>
  290. <div class="party-panel" :class="{ 'is-collapsed': !partiesExpanded }">
  291. <div class="party-panel__header">
  292. <div class="party-panel__title">需方信息</div>
  293. </div>
  294. <div v-if="!partiesExpanded" class="party-panel__summary">
  295. <span class="party-panel__summary-label">单位名称</span>
  296. <span class="party-panel__summary-value" :class="{ 'is-empty': !form.partyB }">
  297. {{ form.partyB || '未填写' }}
  298. </span>
  299. </div>
  300. <div v-show="partiesExpanded" class="form-grid-2">
  301. <el-form-item label="单位名称(章)" prop="partyB">
  302. <el-input v-model="form.partyB" placeholder="从客户开票信息带出" maxlength="200" />
  303. </el-form-item>
  304. <el-form-item label="单位地址" prop="partyBAddress">
  305. <el-input v-model="form.partyBAddress" placeholder="从客户开票信息带出" maxlength="500" />
  306. </el-form-item>
  307. <el-form-item label="法定代表人" prop="partyBLegalPerson">
  308. <el-input v-model="form.partyBLegalPerson" placeholder="从客户开票信息带出" maxlength="100" />
  309. </el-form-item>
  310. <el-form-item label="代理人" prop="partyBAgent">
  311. <el-input v-model="form.partyBAgent" placeholder="从客户开票信息带出(可选)" maxlength="100" />
  312. </el-form-item>
  313. <el-form-item label="电话/传真" prop="partyBPhoneFax">
  314. <el-input v-model="form.partyBPhoneFax" placeholder="从客户开票信息带出" maxlength="100" />
  315. </el-form-item>
  316. <el-form-item label="开户银行" prop="partyBBank">
  317. <el-input v-model="form.partyBBank" placeholder="从客户开票信息带出" maxlength="200" />
  318. </el-form-item>
  319. <el-form-item label="账号" prop="partyBAccount">
  320. <el-input v-model="form.partyBAccount" placeholder="从客户开票信息带出" maxlength="100" />
  321. </el-form-item>
  322. </div>
  323. </div>
  324. </div>
  325. </div>
  326. <!-- 产品明细部分 -->
  327. <el-divider content-position="left">产品明细</el-divider>
  328. <div class="panel dialog-list-panel mb-4">
  329. <div class="list-toolbar">
  330. <div class="list-toolbar-row">
  331. <div class="list-toolbar-selected">
  332. <el-button class="btn-main" type="primary" @click="handleAddProduct">
  333. <el-icon><Plus /></el-icon>添加产品
  334. </el-button>
  335. <el-button class="btn-ghost" @click="handleRemoveSelectedProducts" :disabled="selectedProducts.length === 0">
  336. <el-icon><Delete /></el-icon>删除选中
  337. </el-button>
  338. </div>
  339. </div>
  340. </div>
  341. <div class="list-body list-wrap">
  342. <ListTable table-key="crm-contract-components-contract-form-dialog" :data="form.products" class="list-table" stripe @selection-change="handleProductSelectionChange">
  343. <el-table-column type="selection" width="55" align="center" header-align="center" />
  344. <el-table-column label="序号" type="index" width="60" align="center" header-align="center" />
  345. <el-table-column label="产品名称" min-width="160" :show-overflow-tooltip="true">
  346. <template #default="scope">
  347. <el-input
  348. :model-value="scope.row.standardProductName || scope.row.productName"
  349. placeholder="产品名称"
  350. clearable
  351. size="small"
  352. readonly
  353. />
  354. </template>
  355. </el-table-column>
  356. <el-table-column label="别名" min-width="140">
  357. <template #default="scope">
  358. <div class="line-meta-cell">
  359. <el-select
  360. v-if="(scope.row.aliasOptions || []).length"
  361. v-model="scope.row.aliasName"
  362. placeholder="不选则用产品名称"
  363. clearable
  364. filterable
  365. size="small"
  366. class="line-meta-cell__control"
  367. >
  368. <el-option
  369. v-for="name in scope.row.aliasOptions"
  370. :key="name"
  371. :label="name"
  372. :value="name"
  373. />
  374. </el-select>
  375. <span v-else class="text-muted line-meta-cell__empty">—</span>
  376. <el-button
  377. v-if="scope.row.productId"
  378. link
  379. type="primary"
  380. class="line-meta-cell__btn"
  381. @click="openAliasBrandManage(scope.row, 'alias')"
  382. >
  383. <el-icon><Plus /></el-icon>
  384. </el-button>
  385. </div>
  386. </template>
  387. </el-table-column>
  388. <el-table-column label="品牌" min-width="140">
  389. <template #default="scope">
  390. <div class="line-meta-cell">
  391. <el-select
  392. v-if="(scope.row.brandOptions || []).length"
  393. v-model="scope.row.brandId"
  394. placeholder="不限品牌"
  395. clearable
  396. filterable
  397. size="small"
  398. class="line-meta-cell__control"
  399. @change="(val) => handleBrandIdChange(scope.row, val)"
  400. >
  401. <el-option
  402. v-for="b in scope.row.brandOptions"
  403. :key="b.id"
  404. :label="b.brandName"
  405. :value="b.id"
  406. />
  407. </el-select>
  408. <span v-else class="text-muted line-meta-cell__empty">—</span>
  409. <el-button
  410. v-if="scope.row.productId"
  411. link
  412. type="primary"
  413. class="line-meta-cell__btn"
  414. @click="openAliasBrandManage(scope.row, 'brand')"
  415. >
  416. <el-icon><Plus /></el-icon>
  417. </el-button>
  418. </div>
  419. </template>
  420. </el-table-column>
  421. <el-table-column label="型号/规格" prop="productModel" min-width="100" :show-overflow-tooltip="true">
  422. <template #default="scope">
  423. <el-input v-model="scope.row.productModel" placeholder="请输入型号/规格" clearable size="small" readonly />
  424. </template>
  425. </el-table-column>
  426. <el-table-column label="税率(%)" prop="taxRate" min-width="80">
  427. <template #default="scope">
  428. <el-input v-model="scope.row.taxRate" placeholder="税率" @input="handleTaxRateChange(scope.row)" clearable size="small" />
  429. </template>
  430. </el-table-column>
  431. <el-table-column label="单位" prop="unit" width="100" align="center">
  432. <template #default="scope">
  433. <DictSelect
  434. v-model="scope.row.unit"
  435. dict-type="crm_product_unit"
  436. placeholder="请选择单位"
  437. size="small"
  438. style="width: 100%"
  439. />
  440. </template>
  441. </el-table-column>
  442. <el-table-column prop="quantity" width="130">
  443. <template #header> <span style="color: #f56c6c">*</span> 数量</template>
  444. <template #default="scope">
  445. <div v-if="isFrameworkContract" class="framework-pricing-cell">
  446. <template v-if="isFrameworkQuantityCustomerDemand(scope.row)">
  447. <span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_QUANTITY_LABEL }}</span>
  448. <el-button
  449. link
  450. type="primary"
  451. class="framework-pricing-cell__action"
  452. @click="handleEnableFrameworkQuantityFixed(scope.row)"
  453. >
  454. 填数字
  455. </el-button>
  456. </template>
  457. <template v-else>
  458. <el-input
  459. v-model="scope.row.quantity"
  460. placeholder="数量"
  461. @input="handleQuantityChange(scope.row)"
  462. clearable
  463. size="small"
  464. />
  465. <el-button
  466. link
  467. type="primary"
  468. class="framework-pricing-cell__action"
  469. @click="handleResetFrameworkQuantity(scope.row)"
  470. >
  471. 按客户需求
  472. </el-button>
  473. </template>
  474. </div>
  475. <el-input
  476. v-else
  477. v-model="scope.row.quantity"
  478. placeholder="数量"
  479. @input="handleQuantityChange(scope.row)"
  480. clearable
  481. size="small"
  482. />
  483. </template>
  484. </el-table-column>
  485. <el-table-column prop="price" width="150">
  486. <template #header>
  487. <span v-if="isFrameworkContract">含税单价(元)</span>
  488. <span v-else><span style="color: #f56c6c">*</span> 含税单价(元)</span>
  489. </template>
  490. <template #default="scope">
  491. <div v-if="isFrameworkContract" class="framework-pricing-cell">
  492. <template v-if="isFrameworkPriceMarket(scope.row)">
  493. <span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
  494. <el-button
  495. link
  496. type="primary"
  497. class="framework-pricing-cell__action"
  498. @click="handleEnableFrameworkPriceFixed(scope.row)"
  499. >
  500. 填数字
  501. </el-button>
  502. </template>
  503. <template v-else>
  504. <el-input
  505. v-model="scope.row.price"
  506. placeholder="含税单价"
  507. @input="handlePriceChange(scope.row)"
  508. clearable
  509. size="small"
  510. class="money-field-input"
  511. />
  512. <el-button
  513. link
  514. type="primary"
  515. class="framework-pricing-cell__action"
  516. @click="handleResetFrameworkPrice(scope.row)"
  517. >
  518. 随行就市
  519. </el-button>
  520. </template>
  521. </div>
  522. <el-input
  523. v-else
  524. v-model="scope.row.price"
  525. placeholder="含税单价"
  526. @input="handlePriceChange(scope.row)"
  527. clearable
  528. size="small"
  529. class="money-field-input"
  530. />
  531. </template>
  532. </el-table-column>
  533. <el-table-column prop="subtotal" width="150" align="right">
  534. <template #header>
  535. <span v-if="isFrameworkContract">含税总价(元)</span>
  536. <span v-else><span style="color: #f56c6c">*</span> 含税总价(元)</span>
  537. </template>
  538. <template #default="scope">
  539. <div v-if="isFrameworkContract" class="framework-pricing-cell framework-pricing-cell--money">
  540. <template v-if="isFrameworkPriceMarket(scope.row)">
  541. <span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
  542. <el-button
  543. link
  544. type="primary"
  545. class="framework-pricing-cell__action"
  546. @click="handleEnableFrameworkPriceFixed(scope.row)"
  547. >
  548. 填数字
  549. </el-button>
  550. </template>
  551. <span v-else class="money-display">{{ formatFrameworkMoneyDisplay(scope.row, 'subtotal') }}</span>
  552. </div>
  553. <span v-else class="money-display">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
  554. </template>
  555. </el-table-column>
  556. <el-table-column label="税费(元)" prop="taxAmount" min-width="120" align="right">
  557. <template #default="scope">
  558. <div v-if="isFrameworkContract" class="framework-pricing-cell framework-pricing-cell--money">
  559. <template v-if="isFrameworkPriceMarket(scope.row)">
  560. <span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
  561. <el-button
  562. link
  563. type="primary"
  564. class="framework-pricing-cell__action"
  565. @click="handleEnableFrameworkPriceFixed(scope.row)"
  566. >
  567. 填数字
  568. </el-button>
  569. </template>
  570. <span v-else class="money-display">{{ formatFrameworkMoneyDisplay(scope.row, 'taxAmount') }}</span>
  571. </div>
  572. <span v-else class="money-display">¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
  573. </template>
  574. </el-table-column>
  575. <el-table-column prop="packageSpecs" min-width="220" class-name="col-package-specs">
  576. <template #header> <span style="color: #f56c6c">*</span> 包装规格</template>
  577. <template #default="scope">
  578. <DictSelect
  579. v-model="scope.row.packageSpecs"
  580. dict-type="crm_product_gg"
  581. placeholder="请选择包装规格"
  582. size="small"
  583. multiple
  584. class="package-specs-select"
  585. style="width: 100%"
  586. />
  587. </template>
  588. </el-table-column>
  589. <el-table-column prop="deliveryMethod" min-width="150">
  590. <template #header> <span style="color: #f56c6c">*</span> 送货方式</template>
  591. <template #default="scope">
  592. <DictSelect
  593. v-model="scope.row.deliveryMethod"
  594. dict-type="crm_shfs"
  595. placeholder="请选择送货方式"
  596. size="small"
  597. style="width: 100%"
  598. />
  599. </template>
  600. </el-table-column>
  601. <el-table-column prop="remark" label="备注" min-width="160">
  602. <template #default="scope">
  603. <el-input
  604. v-model="scope.row.remark"
  605. type="textarea"
  606. :rows="2"
  607. placeholder="请输入备注"
  608. maxlength="500"
  609. show-word-limit
  610. clearable
  611. size="small"
  612. />
  613. </template>
  614. </el-table-column>
  615. <el-table-column label="操作" min-width="80" align="center" header-align="center" fixed="right" class-name="col-actions">
  616. <template #default="scope">
  617. <el-button link type="danger" @click="handleRemoveProduct(scope.$index)">
  618. <el-icon><Delete /></el-icon>
  619. </el-button>
  620. </template>
  621. </el-table-column>
  622. </ListTable>
  623. </div>
  624. </div>
  625. <!-- 框架合同调价区间 -->
  626. <template v-if="isFrameworkContractType(form.contractType)">
  627. <el-divider content-position="left">框架合同调价区间</el-divider>
  628. <p class="text-muted mb-3" style="font-size: 13px">
  629. 按产品维护生效日期区间与含税单价(可选);若填写区间则须完整(日期、单价、调价函)。合同总金额不与此联动,仍为 0。
  630. </p>
  631. <div v-for="(prod, pIdx) in form.products" :key="'fw-tier-' + pIdx" class="framework-tier-block mb-4">
  632. <div class="fw-tier-title mb-2">{{ prod.productName || '产品' + (pIdx + 1) }}</div>
  633. <el-button type="primary" link class="mb-2 fw-tier-letter-add-btn" @click="addPriceTierRow(pIdx)">
  634. <el-icon><Plus /></el-icon>调价函
  635. </el-button>
  636. <div class="panel dialog-list-panel mb-3">
  637. <div class="list-body list-wrap dialog-list-body-only">
  638. <ListTable table-key="crm-contract-components-contract-form-dialog-2" :data="prod.priceTiers || []" class="list-table fw-price-tier-table" stripe empty-text="暂无调价区间">
  639. <el-table-column type="index" label="#" width="50" align="center" header-align="center" />
  640. <el-table-column label="开始日期" min-width="150">
  641. <template #default="scope">
  642. <el-date-picker v-model="scope.row.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
  643. </template>
  644. </el-table-column>
  645. <el-table-column label="结束日期" min-width="150">
  646. <template #default="scope">
  647. <el-date-picker v-model="scope.row.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
  648. </template>
  649. </el-table-column>
  650. <el-table-column label="含税单价(元)" width="140">
  651. <template #default="scope">
  652. <el-input v-model="scope.row.price" placeholder="含税单价" clearable size="small" class="money-field-input" />
  653. </template>
  654. </el-table-column>
  655. <el-table-column label="调价函" min-width="220">
  656. <template #default="scope">
  657. <AttachmentUploadField
  658. :model-value="tierLetterCsv(scope.row)"
  659. accept="image/*"
  660. :limit="20"
  661. compact
  662. :biz-type="CRM_UPLOAD_BIZ_TYPE.CONTRACT_PRICE_LETTER"
  663. @update:model-value="syncTierLetterCsv(scope.row, $event)"
  664. />
  665. </template>
  666. </el-table-column>
  667. <el-table-column label="操作" min-width="70" align="center" header-align="center" fixed="right" class-name="col-actions">
  668. <template #default="scope">
  669. <el-button link type="danger" @click="removePriceTierRow(pIdx, scope.$index)">
  670. <el-icon><Delete /></el-icon>
  671. </el-button>
  672. </template>
  673. </el-table-column>
  674. </ListTable>
  675. </div>
  676. </div>
  677. </div>
  678. </template>
  679. <el-divider />
  680. <div class="total-section">
  681. <el-row :gutter="20">
  682. <el-col :span="16"></el-col>
  683. <el-col :span="8">
  684. <div class="total-item">
  685. <span class="total-label">合计金额(元):</span>
  686. <span class="total-value money-display">¥{{ parseFloat(form.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</span>
  687. </div>
  688. </el-col>
  689. </el-row>
  690. </div>
  691. <!-- 附件上传部分 - OpenSpec 标准卡片化布局 -->
  692. <el-divider content-position="left">附件上传</el-divider>
  693. <el-form-item label="附件" prop="attachmentFiles" :required="form.contractTemplate === 'customer_template'">
  694. <ContractAttachmentPanel
  695. v-model="attachmentList"
  696. :biz-type="CRM_UPLOAD_BIZ_TYPE.CONTRACT_ATTACHMENT"
  697. />
  698. </el-form-item>
  699. </el-form>
  700. </div>
  701. <template #footer>
  702. <div class="dialog-footer">
  703. <el-button class="btn-ghost" @click="handleClose">
  704. <el-icon><Close /></el-icon>取消
  705. </el-button>
  706. <el-button
  707. class="btn-ghost"
  708. :loading="saveDraftLoading"
  709. :disabled="formLoading"
  710. @click="handleSaveDraft"
  711. >
  712. <el-icon><FolderChecked /></el-icon>保存草稿
  713. </el-button>
  714. <el-button
  715. class="btn-main"
  716. type="primary"
  717. :loading="submitLoading"
  718. :disabled="formLoading"
  719. @click="handleSubmit"
  720. >
  721. <el-icon><Promotion /></el-icon>提交审批
  722. </el-button>
  723. </div>
  724. </template>
  725. <!-- 产品选择器 -->
  726. <ProductSelector
  727. :visible="productSelectorVisible"
  728. :supplier-id="form.supplierId"
  729. @update:visible="productSelectorVisible = $event"
  730. @confirm="handleProductSelected"
  731. />
  732. <ProductAliasBrandDialog
  733. v-model="aliasBrandManageVisible"
  734. :product-id="aliasBrandManageProductId"
  735. :product-name="aliasBrandManageProductName"
  736. :mode="aliasBrandManageMode"
  737. @saved="handleAliasBrandManaged"
  738. />
  739. <!-- 客户选择器 -->
  740. <CustomerSelector
  741. :visible="customerSelectorVisible"
  742. @update:visible="customerSelectorVisible = $event"
  743. @confirm="handleCustomerSelectorConfirm"
  744. />
  745. <!-- 下拉选客户时补填简称 -->
  746. <el-dialog
  747. v-model="shortNameDialogVisible"
  748. title="填写客户简称"
  749. width="480px"
  750. align-center
  751. class="dialog-form-layout"
  752. modal-class="order-module-dialog-mask"
  753. :close-on-click-modal="false"
  754. append-to-body
  755. @closed="handleShortNameDialogClosed"
  756. >
  757. <el-form
  758. ref="shortNameFormRef"
  759. :model="shortNameForm"
  760. :rules="shortNameRules"
  761. label-position="top"
  762. class="crm-dialog-form"
  763. >
  764. <el-form-item label="客户名称">
  765. <el-input :model-value="shortNameForm.customerName" disabled />
  766. </el-form-item>
  767. <el-form-item label="客户简称" prop="shortName">
  768. <el-input
  769. v-model="shortNameForm.shortName"
  770. placeholder="请输入客户简称"
  771. maxlength="100"
  772. show-word-limit
  773. clearable
  774. />
  775. </el-form-item>
  776. </el-form>
  777. <template #footer>
  778. <div class="dialog-footer">
  779. <el-button class="btn-ghost" @click="shortNameDialogVisible = false">
  780. <el-icon><Close /></el-icon>取消
  781. </el-button>
  782. <el-button class="btn-main" type="primary" :loading="shortNameSaving" @click="handleSaveShortName">
  783. <el-icon><Check /></el-icon>确定
  784. </el-button>
  785. </div>
  786. </template>
  787. </el-dialog>
  788. <!-- 联系人选择器 -->
  789. <ContactSelector
  790. :visible="contactSelectorVisible"
  791. @update:visible="contactSelectorVisible = $event"
  792. :customerId="form.customerId"
  793. @confirm="handleContactConfirm"
  794. />
  795. <!-- 下拉无联系人时补充 -->
  796. <ContactFormDialog ref="contactFormRef" @success="handleContactFormSuccess" />
  797. <!-- 报价单选择器 -->
  798. <QuoteSelector
  799. :visible="quoteSelectorVisible"
  800. @update:visible="quoteSelectorVisible = $event"
  801. :customerId="form.customerId"
  802. @confirm="handleQuoteConfirm"
  803. />
  804. <!-- 用户选择器 -->
  805. <!-- 审批弹窗 -->
  806. <ApprovalDialog ref="approvalDialogRef" @success="handleApprovalSuccess" />
  807. </el-dialog>
  808. </template>
  809. <script setup lang="ts">
  810. import { ref, reactive, computed, nextTick, watch } from 'vue'
  811. import type { FormInstance, FormItemRule } from 'element-plus'
  812. import { ElMessage } from 'element-plus'
  813. import {
  814. getContract,
  815. addContract,
  816. updateContract,
  817. type ContractForm,
  818. type ContractProductForm,
  819. type ContractProductPriceTierForm,
  820. type ContractVO
  821. } from '@/api/crm/contract/index'
  822. import { isFrameworkContractType, isPurchaseLikeContractType } from '../contractType'
  823. import {
  824. FRAMEWORK_QUANTITY_LABEL,
  825. FRAMEWORK_PRICE_LABEL,
  826. applyFrameworkDefaultsToProduct,
  827. createFrameworkProductPricingDefaults,
  828. enableFrameworkPriceFixed,
  829. enableFrameworkQuantityFixed,
  830. formatFrameworkMoneyDisplay,
  831. isBlankNumeric,
  832. isFrameworkPriceMarket,
  833. isFrameworkQuantityCustomerDemand,
  834. isNegativeNumeric,
  835. confirmZeroPricingIfNeeded,
  836. normalizeFrameworkProductPricing,
  837. prepareFrameworkProductForSubmit,
  838. resetFrameworkPriceToMarket,
  839. resetFrameworkQuantityToCustomerDemand
  840. } from '../frameworkProductPricing'
  841. import { useDictStore } from '@/store/modules/dict'
  842. import { useUserStore } from '@/store/modules/user'
  843. import { $modal } from '@/utils/modal'
  844. import {
  845. Document,
  846. Delete,
  847. Sort,
  848. Plus,
  849. Search,
  850. Close,
  851. Check,
  852. FolderChecked,
  853. Promotion,
  854. Loading,
  855. ArrowDown,
  856. ArrowUp
  857. } from '@element-plus/icons-vue'
  858. import {
  859. buildUploadFileListFromField,
  860. serializeContractAttachments,
  861. type ContractAttachmentItem
  862. } from '@/utils/crm-attachment'
  863. import { CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
  864. import ContractAttachmentPanel from './ContractAttachmentPanel.vue'
  865. import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
  866. import { getOpportunity } from '@/api/crm/opportunity'
  867. import { getQuote } from '@/api/crm/quote'
  868. import ProductSelector from '@/components/business/product-selector/index.vue'
  869. import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
  870. import DictSelect from '@/components/DictSelect/index.vue'
  871. import CustomerSelector from '@/components/CustomerSelector.vue'
  872. import ContactSelector from '@/components/ContactSelector.vue'
  873. import ContactFormDialog from '@/views/crm/customer/components/ContactFormDialog.vue'
  874. import QuoteSelector from '@/components/QuoteSelector.vue'
  875. import { fetchContactsByCustomerId } from '@/api/crm/contact'
  876. import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
  877. import {
  878. applyBrandSelectChange,
  879. applyManagedProductMetaToLines,
  880. initLineBrandMetaFromProduct,
  881. prepareLineAliasAndBrandForSubmit,
  882. resolveAliasFromSavedName
  883. } from '@/utils/crm-product-brand'
  884. import { fetchProductDetail } from '@/api/crm/product'
  885. import ApprovalDialog from './ApprovalDialog.vue'
  886. import {
  887. applyPartySwapSnapshots,
  888. clearPartyFields,
  889. omitEmptyExceptPartyFields,
  890. preparePartySwap
  891. } from '../partyFields'
  892. import {
  893. fetchCustomerList,
  894. fetchCustomerDetail,
  895. fetchUpdateCustomer
  896. } from '@/api/crm/customer'
  897. import type { CustomerVO, ContactVO } from '@/api/crm/customer/types'
  898. import { getDefaultInvoiceInfoByCustomerId } from '@/api/crm/invoiceInfo'
  899. import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo'
  900. import { fetchUserOptions } from '@/api/admin'
  901. import { filterOwnerCandidates, findDefaultOwnerUser } from '@/utils/crm-owner-candidates'
  902. import {
  903. buildPackageSpecsPayload,
  904. normalizeContractProductPackageFields,
  905. validateContractProductPackageSpecs
  906. } from '@/utils/crm-package-spec'
  907. interface UserVO {
  908. userId: string | number
  909. nickName: string
  910. userName?: string
  911. }
  912. // 扩展表单类型,添加显示用字段
  913. type ExtendedContractForm = ContractForm & {
  914. customerName?: string
  915. opportunityName?: string
  916. quoteNo?: string
  917. contactsName?: string
  918. contactsPhone?: string
  919. ownerUserName?: string
  920. supplierId?: number
  921. settlementMethod?: string
  922. templateContent?: string
  923. }
  924. // 扩展产品类型,添加显示字段
  925. type PriceTierRow = ContractProductPriceTierForm
  926. type ExtendedContractProductForm = ContractProductForm & {
  927. productName?: string
  928. productModel?: string
  929. unit?: string
  930. unitPrice?: number
  931. subtotal?: number
  932. priceTiers?: PriceTierRow[]
  933. }
  934. const props = withDefaults(
  935. defineProps<{
  936. visible: boolean
  937. editId: string | null
  938. /** 复制来源合同(列表选中行,含 products) */
  939. copySource?: ContractVO | null
  940. /** dialog=桌面弹窗;page=小程序 WebView 全屏页 */
  941. variant?: 'dialog' | 'page'
  942. }>(),
  943. {
  944. copySource: null,
  945. variant: 'dialog'
  946. }
  947. )
  948. const emit = defineEmits<{
  949. 'update:visible': [value: boolean]
  950. success: []
  951. 'loading-change': [loading: boolean]
  952. }>()
  953. const isPageVariant = computed(() => props.variant === 'page')
  954. const dialogVisible = computed({
  955. get: () => props.visible,
  956. set: (val) => emit('update:visible', val)
  957. })
  958. const dictStore = useDictStore()
  959. const userStore = useUserStore()
  960. // 表单标题
  961. const formTitle = computed(() => {
  962. if (props.editId) return '编辑合同'
  963. if (props.copySource) return '复制合同'
  964. return '新增合同'
  965. })
  966. // 合同期限日期范围
  967. const contractDateRange = ref<[string, string]>([])
  968. // 表单数据
  969. const form = ref<ExtendedContractForm>({
  970. id: undefined,
  971. contractNo: undefined,
  972. contractName: '',
  973. contractType: 'sale',
  974. contractTemplate: 'shanxia',
  975. customerId: undefined,
  976. customerName: '',
  977. contactId: undefined,
  978. opportunityId: undefined,
  979. opportunityName: '',
  980. quoteId: undefined,
  981. quoteNo: '',
  982. contactsName: '',
  983. contactsPhone: '',
  984. signDate: '',
  985. startDate: '',
  986. endDate: '',
  987. ownerUserId: undefined,
  988. ownerUserName: '',
  989. amount: 0,
  990. freight: 0,
  991. barrelDeposit: 0,
  992. supplierId: undefined,
  993. settlementMethod: undefined,
  994. partyA: '',
  995. partyAAddress: '',
  996. partyAContact: '',
  997. partyAMobile: '',
  998. partyALegalPerson: '',
  999. partyAAgent: '',
  1000. partyAPhoneFax: '',
  1001. partyABank: '',
  1002. partyAAccount: '',
  1003. partyB: '',
  1004. partyBAddress: '',
  1005. partyBContact: '',
  1006. partyBMobile: '',
  1007. partyBLegalPerson: '',
  1008. partyBAgent: '',
  1009. partyBPhoneFax: '',
  1010. partyBBank: '',
  1011. partyBAccount: '',
  1012. paymentTerms: '',
  1013. deliveryTerms: '',
  1014. contractTerms: '',
  1015. products: [] as ExtendedContractProductForm[],
  1016. remark: ''
  1017. })
  1018. // 供需方是否已调换的标志
  1019. const isPartiesSwapped = ref(false)
  1020. /** 调换后递增,强制重挂载供需方输入框,避免 el-input 显示与 model 不同步 */
  1021. const partiesSwapEpoch = ref(0)
  1022. /** 供方 / 需方信息展开状态(默认缩略,两侧共用) */
  1023. const partiesExpanded = ref(false)
  1024. const resetPartyExpand = () => {
  1025. partiesExpanded.value = false
  1026. }
  1027. /** 校验失败时,自动展开供需方面板 */
  1028. const expandPartiesForInvalidFields = (invalidFields?: Record<string, unknown>) => {
  1029. if (!invalidFields) return
  1030. const keys = Object.keys(invalidFields)
  1031. if (keys.some((k) => k.startsWith('partyA') || k.startsWith('partyB'))) {
  1032. partiesExpanded.value = true
  1033. }
  1034. }
  1035. // 记录原始的模板内容(用于检测是否被意外删除)
  1036. const originalTemplateContent = ref<string>('')
  1037. // 供方信息列表
  1038. const supplierList = ref<SupplierInfoVO[]>([])
  1039. // 客户简称 / 开票公司名(用于按供需方面板匹配简称)
  1040. const customerShortName = ref('')
  1041. const customerInvoiceName = ref('')
  1042. // 表单校验规则
  1043. const rules = ref({
  1044. contractType: [{ required: true, message: '合同类型不能为空', trigger: 'change' }],
  1045. customerId: [{ required: true, message: '客户不能为空', trigger: 'change' }],
  1046. contactId: [{ required: true, message: '联系人不能为空', trigger: 'change' }],
  1047. signDate: [{ required: true, message: '签订日期不能为空', trigger: 'change' }],
  1048. ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'change' }],
  1049. supplierId: [{ required: true, message: '交易主体不能为空', trigger: 'change' }],
  1050. settlementMethod: [{ required: true, message: '结算方式不能为空', trigger: 'change' }],
  1051. startDate: [
  1052. {
  1053. validator: (_rule: any, _value: any, callback: any) => {
  1054. if (!contractDateRange.value || contractDateRange.value.length !== 2 || !contractDateRange.value[0] || !contractDateRange.value[1]) {
  1055. callback(new Error('合同期限不能为空'))
  1056. } else {
  1057. callback()
  1058. }
  1059. },
  1060. trigger: ['change', 'blur']
  1061. }
  1062. ],
  1063. attachmentFiles: []
  1064. })
  1065. // 附件列表
  1066. const attachmentList = ref<ContractAttachmentItem[]>([])
  1067. const isFrameworkContract = computed(() => isFrameworkContractType(form.value.contractType))
  1068. // 加载状态
  1069. const formLoading = ref(false)
  1070. const saveDraftLoading = ref(false)
  1071. const submitLoading = ref(false)
  1072. /** 编辑打开时立即进入加载态,避免空白表单可被误改 */
  1073. watch(
  1074. () => props.visible,
  1075. (visible) => {
  1076. if (visible && (props.editId || props.copySource)) {
  1077. formLoading.value = true
  1078. emit('loading-change', true)
  1079. } else if (!visible) {
  1080. formLoading.value = false
  1081. emit('loading-change', false)
  1082. }
  1083. }
  1084. )
  1085. watch(formLoading, (loading) => {
  1086. if (props.visible) emit('loading-change', loading)
  1087. })
  1088. // 组件引用
  1089. const formRef = ref<FormInstance>()
  1090. const approvalDialogRef = ref()
  1091. // 选择器状态
  1092. const customerSelectorVisible = ref(false)
  1093. const contactSelectorVisible = ref(false)
  1094. const opportunitySelectorVisible = ref(false)
  1095. const quoteSelectorVisible = ref(false)
  1096. const userSelectorVisible = ref(false)
  1097. const productSelectorVisible = ref(false)
  1098. // 客户下拉(远程模糊检索)
  1099. const customerOptions = ref<CustomerVO[]>([])
  1100. const customerOptionsLoading = ref(false)
  1101. /** 已确认选定的客户(用于简称弹窗取消时回滚下拉值) */
  1102. const confirmedCustomerId = ref<string | number | undefined>(undefined)
  1103. const shortNameDialogVisible = ref(false)
  1104. const shortNameSaving = ref(false)
  1105. const shortNameFormRef = ref<FormInstance>()
  1106. const shortNameForm = ref({
  1107. id: '' as string | number,
  1108. customerName: '',
  1109. shortName: ''
  1110. })
  1111. const shortNameRules: Record<string, FormItemRule[]> = {
  1112. shortName: [{ required: true, message: '客户简称不能为空', trigger: 'blur' }]
  1113. }
  1114. const pendingCustomerForShortName = ref<CustomerVO | null>(null)
  1115. const shortNameSavedSuccessfully = ref(false)
  1116. // 联系人下拉
  1117. const contactOptions = ref<ContactVO[]>([])
  1118. const contactOptionsLoading = ref(false)
  1119. const contactFormRef = ref<InstanceType<typeof ContactFormDialog>>()
  1120. /** 避免 visible-change 重复弹出补充联系人弹窗 */
  1121. const contactEmptyPrompted = ref(false)
  1122. const hasCustomerShortName = (row?: { shortName?: string } | null) =>
  1123. Boolean(String(row?.shortName || '').trim())
  1124. const ensureCustomerOption = (customer?: Partial<CustomerVO> | null) => {
  1125. if (!customer?.id) return
  1126. const exists = customerOptions.value.some((item) => String(item.id) === String(customer.id))
  1127. if (exists) {
  1128. customerOptions.value = customerOptions.value.map((item) =>
  1129. String(item.id) === String(customer.id)
  1130. ? {
  1131. ...item,
  1132. customerName: customer.customerName || item.customerName,
  1133. customerNo: customer.customerNo ?? item.customerNo,
  1134. shortName: customer.shortName ?? item.shortName
  1135. }
  1136. : item
  1137. )
  1138. return
  1139. }
  1140. customerOptions.value = [
  1141. {
  1142. id: customer.id,
  1143. customerName: customer.customerName || '',
  1144. customerNo: customer.customerNo,
  1145. shortName: customer.shortName
  1146. } as CustomerVO,
  1147. ...customerOptions.value
  1148. ]
  1149. }
  1150. const extractCustomerList = (res: unknown): CustomerVO[] => {
  1151. const data = (res as { data?: unknown })?.data ?? res ?? {}
  1152. const list = (data as { list?: CustomerVO[]; rows?: CustomerVO[] }).list
  1153. || (data as { rows?: CustomerVO[] }).rows
  1154. || []
  1155. return Array.isArray(list) ? list : []
  1156. }
  1157. const loadDefaultCustomerOptions = async () => {
  1158. customerOptionsLoading.value = true
  1159. try {
  1160. const res = await fetchCustomerList({ page: 1, pageSize: 20 })
  1161. customerOptions.value = extractCustomerList(res)
  1162. if (form.value.customerId) {
  1163. ensureCustomerOption({
  1164. id: form.value.customerId,
  1165. customerName: form.value.customerName,
  1166. shortName: customerShortName.value
  1167. })
  1168. }
  1169. } catch (error) {
  1170. console.error('加载客户列表失败:', error)
  1171. customerOptions.value = []
  1172. } finally {
  1173. customerOptionsLoading.value = false
  1174. }
  1175. }
  1176. const searchCustomerOptions = async (query: string) => {
  1177. const keyword = query?.trim()
  1178. if (!keyword) {
  1179. await loadDefaultCustomerOptions()
  1180. return
  1181. }
  1182. customerOptionsLoading.value = true
  1183. try {
  1184. const res = await fetchCustomerList({
  1185. customerName: keyword,
  1186. page: 1,
  1187. pageSize: 50
  1188. })
  1189. customerOptions.value = extractCustomerList(res)
  1190. if (form.value.customerId) {
  1191. ensureCustomerOption({
  1192. id: form.value.customerId,
  1193. customerName: form.value.customerName,
  1194. shortName: customerShortName.value
  1195. })
  1196. }
  1197. } catch (error) {
  1198. console.error('搜索客户失败:', error)
  1199. customerOptions.value = []
  1200. } finally {
  1201. customerOptionsLoading.value = false
  1202. }
  1203. }
  1204. const handleCustomerSelectFocus = () => {
  1205. if (customerOptions.value.length === 0) {
  1206. loadDefaultCustomerOptions()
  1207. }
  1208. }
  1209. const extractContactList = (res: unknown): ContactVO[] => {
  1210. const unwrapped = (res as { data?: unknown })?.data ?? res ?? []
  1211. if (Array.isArray(unwrapped)) return unwrapped as ContactVO[]
  1212. const payload = (unwrapped as { list?: ContactVO[]; rows?: ContactVO[] }) || {}
  1213. return payload.list || payload.rows || []
  1214. }
  1215. const ensureContactOption = (contact?: Partial<ContactVO> | null) => {
  1216. if (!contact?.id) return
  1217. const exists = contactOptions.value.some((item) => String(item.id) === String(contact.id))
  1218. if (exists) {
  1219. contactOptions.value = contactOptions.value.map((item) =>
  1220. String(item.id) === String(contact.id)
  1221. ? {
  1222. ...item,
  1223. contactName: contact.contactName || item.contactName,
  1224. mobile: contact.mobile ?? item.mobile,
  1225. isPrimary: contact.isPrimary ?? item.isPrimary
  1226. }
  1227. : item
  1228. )
  1229. return
  1230. }
  1231. contactOptions.value = [
  1232. {
  1233. id: contact.id,
  1234. contactName: contact.contactName || '',
  1235. mobile: contact.mobile,
  1236. isPrimary: contact.isPrimary
  1237. } as ContactVO,
  1238. ...contactOptions.value
  1239. ]
  1240. }
  1241. const loadContactOptions = async (customerId?: string | number) => {
  1242. if (!customerId) {
  1243. contactOptions.value = []
  1244. return []
  1245. }
  1246. contactOptionsLoading.value = true
  1247. try {
  1248. const res = await fetchContactsByCustomerId({ customerId })
  1249. contactOptions.value = extractContactList(res)
  1250. if (form.value.contactId) {
  1251. ensureContactOption({
  1252. id: form.value.contactId,
  1253. contactName: form.value.contactsName
  1254. })
  1255. }
  1256. return contactOptions.value
  1257. } catch (error) {
  1258. console.error('加载联系人列表失败:', error)
  1259. contactOptions.value = []
  1260. return []
  1261. } finally {
  1262. contactOptionsLoading.value = false
  1263. }
  1264. }
  1265. const openContactFormDialog = () => {
  1266. if (!form.value.customerId) {
  1267. $modal.msgWarning('请先选择客户')
  1268. return
  1269. }
  1270. contactFormRef.value?.open(undefined, form.value.customerId)
  1271. }
  1272. const openShortNameDialog = (customer: CustomerVO) => {
  1273. pendingCustomerForShortName.value = customer
  1274. shortNameSavedSuccessfully.value = false
  1275. shortNameForm.value = {
  1276. id: customer.id,
  1277. customerName: customer.customerName || '',
  1278. shortName: customer.shortName || ''
  1279. }
  1280. shortNameDialogVisible.value = true
  1281. nextTick(() => shortNameFormRef.value?.clearValidate())
  1282. }
  1283. const clearCustomerSelection = () => {
  1284. form.value.customerId = undefined
  1285. form.value.customerName = ''
  1286. customerShortName.value = ''
  1287. customerInvoiceName.value = ''
  1288. confirmedCustomerId.value = undefined
  1289. form.value.contactId = undefined
  1290. form.value.contactsName = ''
  1291. form.value.contactsPhone = ''
  1292. contactOptions.value = []
  1293. contactEmptyPrompted.value = false
  1294. form.value.opportunityId = undefined
  1295. form.value.opportunityName = ''
  1296. form.value.quoteId = undefined
  1297. form.value.quoteNo = ''
  1298. form.value.freight = 0
  1299. form.value.barrelDeposit = 0
  1300. rebuildContractName()
  1301. }
  1302. // 用户列表(负责人下拉仅方金强 / 吴九华)
  1303. const userList = ref<any[]>([])
  1304. const ownerUserList = computed(() => filterOwnerCandidates(userList.value))
  1305. // 选中的产品列表
  1306. const selectedProducts = ref<ExtendedContractProductForm[]>([])
  1307. // 临时接口定义
  1308. interface OpportunityVO {
  1309. id: number
  1310. opportunityName: string
  1311. customerId?: number
  1312. customerName?: string
  1313. }
  1314. interface QuoteVO {
  1315. id: number
  1316. quoteNo: string
  1317. quoteTitle: string
  1318. customerId?: number
  1319. customerName?: string
  1320. contactId?: number
  1321. contactName?: string
  1322. productList?: any[]
  1323. quoteDate?: string
  1324. }
  1325. /** 对话框打开后的初始化 */
  1326. const handleOpened = async () => {
  1327. resetPartyExpand()
  1328. // 组件实例复用时必须先清空;否则无附件合同会残留上一份合同的 attachmentList
  1329. resetForm()
  1330. const isEdit = !!props.editId
  1331. const isCopy = !!props.copySource
  1332. if (isEdit || isCopy || isPageVariant.value) {
  1333. formLoading.value = true
  1334. }
  1335. try {
  1336. await Promise.all([loadSupplierList(), loadUserList()])
  1337. if (props.editId) {
  1338. await loadContractDetail(props.editId)
  1339. } else if (props.copySource) {
  1340. await loadContractForCopy(props.copySource)
  1341. } else {
  1342. await initNewContractForm()
  1343. }
  1344. } catch (error: any) {
  1345. console.error('合同表单初始化失败:', error)
  1346. $modal.msgError(error?.message || '加载合同数据失败')
  1347. if (isEdit || isCopy) {
  1348. dialogVisible.value = false
  1349. }
  1350. } finally {
  1351. formLoading.value = false
  1352. }
  1353. }
  1354. /** 新增合同表单默认值 */
  1355. const initNewContractForm = async () => {
  1356. resetForm()
  1357. form.value.signDate = new Date().toISOString().split('T')[0]
  1358. handleSignDateChange(form.value.signDate)
  1359. applyDefaultOwnerUser()
  1360. const defaultSupplier = supplierList.value.find((supplier) => supplier.supplierCode === 'SX')
  1361. if (defaultSupplier) {
  1362. form.value.supplierId = defaultSupplier.id
  1363. await handleSupplierChange(defaultSupplier.id)
  1364. }
  1365. }
  1366. /** 重置表单 */
  1367. const resetForm = () => {
  1368. form.value = {
  1369. id: undefined,
  1370. contractNo: undefined,
  1371. contractName: '',
  1372. contractType: 'sale',
  1373. contractTemplate: 'shanxia',
  1374. customerId: undefined,
  1375. customerName: '',
  1376. contactId: undefined,
  1377. opportunityId: undefined,
  1378. opportunityName: '',
  1379. quoteId: undefined,
  1380. quoteNo: '',
  1381. contactsName: '',
  1382. contactsPhone: '',
  1383. signDate: '',
  1384. startDate: '',
  1385. endDate: '',
  1386. ownerUserId: undefined,
  1387. ownerUserName: '',
  1388. amount: 0,
  1389. freight: 0,
  1390. barrelDeposit: 0,
  1391. supplierId: undefined,
  1392. settlementMethod: undefined,
  1393. partyA: '',
  1394. partyAAddress: '',
  1395. partyAContact: '',
  1396. partyAMobile: '',
  1397. partyALegalPerson: '',
  1398. partyAAgent: '',
  1399. partyAPhoneFax: '',
  1400. partyABank: '',
  1401. partyAAccount: '',
  1402. partyB: '',
  1403. partyBAddress: '',
  1404. partyBContact: '',
  1405. partyBMobile: '',
  1406. partyBLegalPerson: '',
  1407. partyBAgent: '',
  1408. partyBPhoneFax: '',
  1409. partyBBank: '',
  1410. partyBAccount: '',
  1411. paymentTerms: '',
  1412. deliveryTerms: '',
  1413. contractTerms: '',
  1414. products: [] as ExtendedContractProductForm[],
  1415. remark: '',
  1416. attachmentFiles: ''
  1417. }
  1418. isPartiesSwapped.value = false
  1419. partiesSwapEpoch.value = 0
  1420. resetPartyExpand()
  1421. originalTemplateContent.value = ''
  1422. customerShortName.value = ''
  1423. customerInvoiceName.value = ''
  1424. confirmedCustomerId.value = undefined
  1425. customerOptions.value = []
  1426. contactOptions.value = []
  1427. contactEmptyPrompted.value = false
  1428. pendingCustomerForShortName.value = null
  1429. shortNameDialogVisible.value = false
  1430. shortNameSavedSuccessfully.value = false
  1431. contractDateRange.value = [] as any
  1432. attachmentList.value = []
  1433. selectedProducts.value = []
  1434. }
  1435. /** 将源合同产品明细映射为表单行(复制时清除主键及数量/单价/备注) */
  1436. const mapContractProductsFromSource = (products: any[], forCopy = false) =>
  1437. products.map((product: any) => {
  1438. const mapped: ExtendedContractProductForm = {
  1439. ...product,
  1440. id: forCopy ? undefined : product.id,
  1441. contractId: forCopy ? undefined : product.contractId,
  1442. productModel: product.model || product.productModel || '',
  1443. isToxic: Number(product.isToxic || 0),
  1444. isExplosive: Number(product.isExplosive || 0),
  1445. brandMode: product.brandMode || undefined,
  1446. brandId: product.brandId || undefined,
  1447. brandName: product.brandName || undefined,
  1448. brandOptions: [],
  1449. priceTiers: (product.priceTiers || []).map((t: any) => {
  1450. let ids: string[] = []
  1451. if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
  1452. ids = t.letterOssIds.map((id: any) => String(id))
  1453. } else if (t.letterOssId != null && t.letterOssId !== '') {
  1454. ids = [String(t.letterOssId)]
  1455. }
  1456. return {
  1457. ...t,
  1458. id: forCopy ? undefined : t.id,
  1459. contractId: forCopy ? undefined : t.contractId,
  1460. contractProductId: forCopy ? undefined : t.contractProductId,
  1461. letterOssIds: ids,
  1462. ...(forCopy ? { price: undefined } : {})
  1463. }
  1464. }),
  1465. taxRate: product.taxRate || 6,
  1466. taxAmount: forCopy ? 0 : (product.taxAmount ?? 0),
  1467. deliveryMethod: product.deliveryMethod || ''
  1468. }
  1469. normalizeContractProductPackageFields(mapped)
  1470. if (forCopy) {
  1471. mapped.quantity = 0
  1472. mapped.price = 0
  1473. mapped.unitPrice = 0
  1474. mapped.subtotal = 0
  1475. mapped.remark = ''
  1476. }
  1477. return mapped
  1478. })
  1479. /** 写入产品明细并刷新金额 */
  1480. const applyContractProductsToForm = async (products: ExtendedContractProductForm[], contractType: string) => {
  1481. form.value.products = products
  1482. await enrichContractProductBrandMeta(form.value.products)
  1483. const isFw = isFrameworkContractType(contractType)
  1484. form.value.products.forEach((product: any) => {
  1485. normalizeFrameworkProductPricing(product, isFw)
  1486. if (!isFw || !isFrameworkPriceMarket(product)) {
  1487. calculateProductAmount(product)
  1488. }
  1489. })
  1490. calculateTotalAmount()
  1491. }
  1492. /** 复制合同:仅带入产品明细,客户等信息由用户重新填写 */
  1493. const loadContractForCopy = async (source: ContractVO) => {
  1494. await initNewContractForm()
  1495. const contractType = source.contractType || 'sale'
  1496. form.value.contractType = contractType
  1497. let products = Array.isArray(source.products) ? source.products : []
  1498. // 列表 includeProducts 不含价阶;框架合同尝试拉详情补全,失败则回退列表明细
  1499. if (isFrameworkContractType(contractType)) {
  1500. try {
  1501. const res = await getContract(source.id)
  1502. if (res.data?.products?.length) {
  1503. products = res.data.products
  1504. form.value.contractType = res.data.contractType || contractType
  1505. }
  1506. } catch (error) {
  1507. console.warn('框架合同复制:加载详情价阶失败,使用列表产品明细', error)
  1508. }
  1509. }
  1510. if (!products.length) {
  1511. const res = await getContract(source.id)
  1512. products = res.data?.products || []
  1513. form.value.contractType = res.data?.contractType || contractType
  1514. }
  1515. if (products.length) {
  1516. const mapped = mapContractProductsFromSource(products, true)
  1517. await applyContractProductsToForm(mapped, form.value.contractType)
  1518. }
  1519. }
  1520. /** 加载合同详情 */
  1521. const loadContractDetail = async (id: number | string) => {
  1522. const res = await getContract(id)
  1523. const contractData = res.data
  1524. Object.assign(form.value, contractData)
  1525. originalTemplateContent.value = contractData.templateContent || ''
  1526. if (contractData.contactName) {
  1527. form.value.contactsName = contractData.contactName
  1528. }
  1529. if (contractData.contactMobile) {
  1530. form.value.contactsPhone = contractData.contactMobile
  1531. }
  1532. if (contractData.ownerUserName) {
  1533. form.value.ownerUserName = contractData.ownerUserName
  1534. }
  1535. if (contractData.ownerUserId) {
  1536. form.value.ownerUserId = contractData.ownerUserId
  1537. }
  1538. if (contractData.opportunityId) {
  1539. try {
  1540. const oppRes = await getOpportunity(contractData.opportunityId)
  1541. if (oppRes.data) {
  1542. form.value.opportunityName = oppRes.data.opportunityName
  1543. }
  1544. } catch (error) {
  1545. console.warn('加载商机信息失败:', error)
  1546. }
  1547. }
  1548. if (contractData.quoteId) {
  1549. try {
  1550. const quoteRes = await getQuote(contractData.quoteId)
  1551. if (quoteRes.data) {
  1552. form.value.quoteNo = quoteRes.data.quoteNo
  1553. }
  1554. } catch (error) {
  1555. console.warn('加载报价单信息失败:', error)
  1556. }
  1557. }
  1558. if (contractData.products && Array.isArray(contractData.products)) {
  1559. const products = mapContractProductsFromSource(contractData.products)
  1560. await applyContractProductsToForm(products, contractData.contractType)
  1561. }
  1562. if (form.value.startDate && form.value.endDate) {
  1563. contractDateRange.value = [form.value.startDate, form.value.endDate]
  1564. }
  1565. // 无条件同步:空附件也必须清空列表,避免沿用上一份合同的附件
  1566. await handleAttachmentData()
  1567. customerShortName.value = ''
  1568. customerInvoiceName.value = ''
  1569. confirmedCustomerId.value = form.value.customerId || undefined
  1570. contactEmptyPrompted.value = false
  1571. if (form.value.customerId) {
  1572. try {
  1573. const [invoiceRes, customerRes] = await Promise.all([
  1574. getDefaultInvoiceInfoByCustomerId(form.value.customerId),
  1575. fetchCustomerDetail({ id: String(form.value.customerId) })
  1576. ])
  1577. customerInvoiceName.value = invoiceRes?.data?.companyName || ''
  1578. customerShortName.value = customerRes?.data?.shortName || ''
  1579. if (!form.value.customerName && customerRes?.data?.customerName) {
  1580. form.value.customerName = customerRes.data.customerName
  1581. }
  1582. ensureCustomerOption({
  1583. id: form.value.customerId,
  1584. customerName: form.value.customerName || customerRes?.data?.customerName,
  1585. shortName: customerShortName.value,
  1586. customerNo: customerRes?.data?.customerNo
  1587. })
  1588. } catch {
  1589. customerInvoiceName.value = ''
  1590. ensureCustomerOption({
  1591. id: form.value.customerId,
  1592. customerName: form.value.customerName,
  1593. shortName: customerShortName.value
  1594. })
  1595. }
  1596. await loadContactOptions(form.value.customerId)
  1597. if (form.value.contactId) {
  1598. ensureContactOption({
  1599. id: form.value.contactId,
  1600. contactName: form.value.contactsName,
  1601. mobile: form.value.contactsPhone
  1602. })
  1603. }
  1604. } else {
  1605. customerOptions.value = []
  1606. contactOptions.value = []
  1607. }
  1608. syncPartiesSwappedFlagFromPanels()
  1609. rebuildContractName()
  1610. }
  1611. /** 选择客户 */
  1612. const handleSelectCustomer = () => {
  1613. customerSelectorVisible.value = true
  1614. }
  1615. /** 下拉选择客户 */
  1616. const handleCustomerSelectChange = async (customerId?: string | number) => {
  1617. if (!customerId) {
  1618. clearCustomerSelection()
  1619. return
  1620. }
  1621. if (
  1622. confirmedCustomerId.value !== undefined
  1623. && confirmedCustomerId.value !== null
  1624. && String(customerId) === String(confirmedCustomerId.value)
  1625. ) {
  1626. return
  1627. }
  1628. const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
  1629. if (!customer) return
  1630. if (!hasCustomerShortName(customer)) {
  1631. ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续')
  1632. form.value.customerId = confirmedCustomerId.value as any
  1633. openShortNameDialog(customer)
  1634. return
  1635. }
  1636. await handleCustomerConfirm(customer)
  1637. }
  1638. /** 客户选择器确认(无简称时先补填) */
  1639. const handleCustomerSelectorConfirm = async (customer: CustomerVO) => {
  1640. let enriched: CustomerVO = { ...customer }
  1641. if (!hasCustomerShortName(enriched)) {
  1642. try {
  1643. const customerRes = await fetchCustomerDetail({ id: String(customer.id) })
  1644. enriched = {
  1645. ...enriched,
  1646. customerName: customerRes?.data?.customerName || enriched.customerName,
  1647. shortName: customerRes?.data?.shortName || enriched.shortName
  1648. }
  1649. } catch {
  1650. // ignore,后续走简称弹窗
  1651. }
  1652. }
  1653. if (!hasCustomerShortName(enriched)) {
  1654. ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续')
  1655. openShortNameDialog(enriched)
  1656. return
  1657. }
  1658. await handleCustomerConfirm(enriched)
  1659. }
  1660. /** 客户选择确认 */
  1661. const handleCustomerConfirm = async (customer: CustomerVO) => {
  1662. if (customer) {
  1663. ensureCustomerOption(customer)
  1664. form.value.customerId = customer.id
  1665. form.value.customerName = customer.customerName
  1666. confirmedCustomerId.value = customer.id
  1667. const addressParts: string[] = []
  1668. if (customer.province) addressParts.push(customer.province)
  1669. if (customer.city) addressParts.push(customer.city)
  1670. if (customer.district) addressParts.push(customer.district)
  1671. if (customer.address) addressParts.push(customer.address)
  1672. const fullAddress = addressParts.join('')
  1673. form.value.contactId = undefined
  1674. form.value.contactsName = ''
  1675. contactOptions.value = []
  1676. contactEmptyPrompted.value = false
  1677. form.value.opportunityId = undefined
  1678. form.value.opportunityName = ''
  1679. form.value.quoteId = undefined
  1680. form.value.quoteNo = ''
  1681. form.value.freight = 0
  1682. form.value.barrelDeposit = 0
  1683. try {
  1684. const [invoiceRes, customerRes] = await Promise.all([
  1685. getDefaultInvoiceInfoByCustomerId(customer.id),
  1686. fetchCustomerDetail({ id: String(customer.id) })
  1687. ])
  1688. const invoiceData = invoiceRes?.data
  1689. // 简称以客户主数据为准(列表/选择器可能缺字段)
  1690. customerShortName.value =
  1691. (customerRes?.data?.shortName || customer.shortName || '').trim()
  1692. if (customerRes?.data?.customerName) {
  1693. form.value.customerName = customerRes.data.customerName
  1694. }
  1695. ensureCustomerOption({
  1696. id: customer.id,
  1697. customerName: form.value.customerName,
  1698. shortName: customerShortName.value,
  1699. customerNo: customer.customerNo
  1700. })
  1701. customerInvoiceName.value = invoiceData?.companyName || ''
  1702. if (invoiceData && invoiceData.companyName) {
  1703. if (isPartiesSwapped.value) {
  1704. form.value.partyA = invoiceData.companyName || ''
  1705. form.value.partyAAddress = invoiceData.companyAddress || fullAddress || ''
  1706. form.value.partyALegalPerson = invoiceData.legalRepresentative || ''
  1707. form.value.partyAAgent = invoiceData.agent || ''
  1708. form.value.partyAPhoneFax = invoiceData.phoneFax || ''
  1709. form.value.partyABank = invoiceData.bankName || ''
  1710. form.value.partyAAccount = invoiceData.bankAccount || ''
  1711. } else {
  1712. form.value.partyB = invoiceData.companyName || ''
  1713. form.value.partyBAddress = invoiceData.companyAddress || fullAddress || ''
  1714. form.value.partyBLegalPerson = invoiceData.legalRepresentative || ''
  1715. form.value.partyBAgent = invoiceData.agent || ''
  1716. form.value.partyBPhoneFax = invoiceData.phoneFax || ''
  1717. form.value.partyBBank = invoiceData.bankName || ''
  1718. form.value.partyBAccount = invoiceData.bankAccount || ''
  1719. }
  1720. } else {
  1721. if (isPartiesSwapped.value) {
  1722. form.value.partyA = customer.customerName
  1723. form.value.partyAAddress = fullAddress
  1724. form.value.partyALegalPerson = ''
  1725. form.value.partyAAgent = ''
  1726. form.value.partyAPhoneFax = customer.telephone || ''
  1727. form.value.partyABank = ''
  1728. form.value.partyAAccount = ''
  1729. } else {
  1730. form.value.partyB = customer.customerName
  1731. form.value.partyBAddress = fullAddress
  1732. form.value.partyBLegalPerson = ''
  1733. form.value.partyBAgent = ''
  1734. form.value.partyBPhoneFax = customer.telephone || ''
  1735. form.value.partyBBank = ''
  1736. form.value.partyBAccount = ''
  1737. }
  1738. }
  1739. } catch (error) {
  1740. console.error('获取客户开票信息失败:', error)
  1741. customerInvoiceName.value = ''
  1742. customerShortName.value = (customer.shortName || '').trim()
  1743. if (isPartiesSwapped.value) {
  1744. form.value.partyA = customer.customerName
  1745. form.value.partyAAddress = fullAddress
  1746. form.value.partyALegalPerson = ''
  1747. form.value.partyAAgent = ''
  1748. form.value.partyAPhoneFax = customer.telephone || ''
  1749. form.value.partyABank = ''
  1750. form.value.partyAAccount = ''
  1751. } else {
  1752. form.value.partyB = customer.customerName
  1753. form.value.partyBAddress = fullAddress
  1754. form.value.partyBLegalPerson = ''
  1755. form.value.partyBAgent = ''
  1756. form.value.partyBPhoneFax = customer.telephone || ''
  1757. form.value.partyBBank = ''
  1758. form.value.partyBAccount = ''
  1759. }
  1760. }
  1761. if (isPartiesSwapped.value) {
  1762. form.value.partyAContact = ''
  1763. form.value.partyAMobile = customer.mobile || customer.telephone || ''
  1764. } else {
  1765. form.value.partyBContact = ''
  1766. form.value.partyBMobile = customer.mobile || customer.telephone || ''
  1767. }
  1768. form.value.contactsName = ''
  1769. form.value.contactsPhone = customer.mobile || customer.telephone || ''
  1770. // 自动加载该客户的联系人并设置默认联系人;无联系人时立即引导补充
  1771. const contacts = await loadContactOptions(customer.id)
  1772. if (contacts.length > 0) {
  1773. const primaryContact = contacts.find((c) => c.isPrimary === 1)
  1774. const contact = primaryContact || contacts[0]
  1775. if (contact) {
  1776. handleContactConfirm(contact)
  1777. }
  1778. } else {
  1779. contactEmptyPrompted.value = true
  1780. ElMessage.warning('该客户暂无联系人,请先补充联系人信息')
  1781. nextTick(() => openContactFormDialog())
  1782. }
  1783. if (!customerShortName.value) {
  1784. customerShortName.value = (customer.shortName || '').trim()
  1785. }
  1786. rebuildContractName()
  1787. }
  1788. }
  1789. const handleSaveShortName = async () => {
  1790. const valid = await shortNameFormRef.value?.validate().catch(() => false)
  1791. if (!valid) return
  1792. shortNameSaving.value = true
  1793. try {
  1794. const trimmedShortName = shortNameForm.value.shortName.trim()
  1795. await fetchUpdateCustomer({
  1796. id: shortNameForm.value.id,
  1797. shortName: trimmedShortName
  1798. })
  1799. ElMessage.success('客户简称保存成功')
  1800. const pending = pendingCustomerForShortName.value
  1801. const customer: CustomerVO = {
  1802. id: shortNameForm.value.id,
  1803. customerName: shortNameForm.value.customerName || pending?.customerName || '',
  1804. customerNo: pending?.customerNo,
  1805. shortName: trimmedShortName
  1806. } as CustomerVO
  1807. ensureCustomerOption(customer)
  1808. shortNameSavedSuccessfully.value = true
  1809. shortNameDialogVisible.value = false
  1810. await handleCustomerConfirm(customer)
  1811. } catch (error) {
  1812. console.error('保存客户简称失败:', error)
  1813. ElMessage.error('保存客户简称失败')
  1814. } finally {
  1815. shortNameSaving.value = false
  1816. }
  1817. }
  1818. const handleShortNameDialogClosed = () => {
  1819. pendingCustomerForShortName.value = null
  1820. if (!shortNameSavedSuccessfully.value) {
  1821. form.value.customerId = confirmedCustomerId.value as any
  1822. }
  1823. shortNameSavedSuccessfully.value = false
  1824. }
  1825. /** 选择联系人 */
  1826. const handleSelectContact = () => {
  1827. if (!form.value.customerId) {
  1828. $modal.msgWarning('请先选择客户')
  1829. return
  1830. }
  1831. contactSelectorVisible.value = true
  1832. }
  1833. const handleContactSelectVisible = async (visible: boolean) => {
  1834. if (!visible) {
  1835. contactEmptyPrompted.value = false
  1836. return
  1837. }
  1838. if (!form.value.customerId) return
  1839. const list = await loadContactOptions(form.value.customerId)
  1840. if (list.length === 0 && !contactEmptyPrompted.value) {
  1841. contactEmptyPrompted.value = true
  1842. ElMessage.warning('该客户暂无联系人,请先补充联系人信息')
  1843. nextTick(() => openContactFormDialog())
  1844. }
  1845. }
  1846. const handleContactSelectChange = (contactId?: string | number) => {
  1847. if (!contactId) {
  1848. form.value.contactId = undefined
  1849. form.value.contactsName = ''
  1850. form.value.contactsPhone = ''
  1851. return
  1852. }
  1853. const contact = contactOptions.value.find((item) => String(item.id) === String(contactId))
  1854. if (contact) {
  1855. handleContactConfirm(contact)
  1856. }
  1857. }
  1858. /** 联系人选择确认 */
  1859. const handleContactConfirm = (contact: ContactVO) => {
  1860. if (contact) {
  1861. ensureContactOption(contact)
  1862. form.value.contactId = contact.id
  1863. form.value.contactsName = contact.contactName
  1864. form.value.contactsPhone = contact.mobile || contact.telephone || ''
  1865. if (isPartiesSwapped.value) {
  1866. form.value.partyAContact = contact.contactName
  1867. form.value.partyAMobile = contact.mobile || contact.telephone || ''
  1868. } else {
  1869. form.value.partyBContact = contact.contactName
  1870. form.value.partyBMobile = contact.mobile || contact.telephone || ''
  1871. }
  1872. formRef.value?.validateField('contactId')
  1873. }
  1874. }
  1875. const handleContactFormSuccess = async (payload?: { id?: string | number }) => {
  1876. contactEmptyPrompted.value = false
  1877. const list = await loadContactOptions(form.value.customerId)
  1878. const createdId = payload?.id
  1879. if (createdId) {
  1880. const created = list.find((item) => String(item.id) === String(createdId))
  1881. if (created) {
  1882. handleContactConfirm(created)
  1883. return
  1884. }
  1885. }
  1886. if (list.length === 1) {
  1887. handleContactConfirm(list[0])
  1888. }
  1889. }
  1890. /** 选择商机 */
  1891. const handleSelectOpportunity = () => {
  1892. opportunitySelectorVisible.value = true
  1893. }
  1894. /** 商机选择确认 */
  1895. const handleOpportunityConfirm = (opportunity: OpportunityVO) => {
  1896. if (opportunity) {
  1897. form.value.opportunityId = opportunity.id
  1898. form.value.opportunityName = opportunity.opportunityName
  1899. if (opportunity.customerId && !form.value.customerId) {
  1900. form.value.customerId = opportunity.customerId
  1901. form.value.customerName = opportunity.customerName || ''
  1902. }
  1903. }
  1904. }
  1905. /** 选择报价单 */
  1906. const handleSelectQuote = () => {
  1907. quoteSelectorVisible.value = true
  1908. }
  1909. /** 报价单选择确认 */
  1910. const handleQuoteConfirm = (quote: QuoteVO) => {
  1911. if (quote) {
  1912. form.value.quoteId = quote.id
  1913. form.value.quoteNo = quote.quoteNo
  1914. form.value.freight = Number(quote.freight) || 0
  1915. form.value.barrelDeposit = Number(quote.barrelDeposit) || 0
  1916. if (quote.customerId && !form.value.customerId) {
  1917. form.value.customerId = quote.customerId
  1918. form.value.customerName = quote.customerName || ''
  1919. }
  1920. if (quote.contactId) {
  1921. form.value.contactId = quote.contactId
  1922. form.value.contactsName = quote.contactName || ''
  1923. if (isPartiesSwapped.value) {
  1924. form.value.partyAContact = quote.contactName || ''
  1925. } else {
  1926. form.value.partyBContact = quote.contactName || ''
  1927. }
  1928. }
  1929. if (quote.productList && quote.productList.length > 0) {
  1930. $modal
  1931. .confirm('是否要从报价单中带入产品明细?')
  1932. .then(async () => {
  1933. const isFw = isFrameworkContractType(form.value.contractType)
  1934. form.value.products = quote.productList!.map((item: any) => {
  1935. const hasQuotePrice = Number(item.price) > 0 || Number(item.subtotal) > 0
  1936. const hasQuoteQty = Number(item.quantity) > 0
  1937. const fwDefaults = createFrameworkProductPricingDefaults()
  1938. return {
  1939. productId: item.productId,
  1940. productName: item.productName,
  1941. productModel: item.model,
  1942. categoryId: item.categoryId,
  1943. categoryName: item.categoryName || '',
  1944. unit: item.unit || '个',
  1945. ...(isFw
  1946. ? {
  1947. ...fwDefaults,
  1948. quantityMode: hasQuoteQty ? 'fixed' : fwDefaults.quantityMode,
  1949. quantity: hasQuoteQty ? item.quantity || 1 : 0,
  1950. priceMode: hasQuotePrice ? 'fixed' : fwDefaults.priceMode,
  1951. price: hasQuotePrice ? item.price || 0 : 0,
  1952. taxAmount: hasQuotePrice ? item.taxAmount || 0 : 0,
  1953. subtotal: hasQuotePrice
  1954. ? item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100)
  1955. : 0
  1956. }
  1957. : {
  1958. quantity: item.quantity || 1,
  1959. price: item.price || 0,
  1960. unitPrice: item.price || 0,
  1961. taxAmount: item.taxAmount || 0,
  1962. subtotal:
  1963. item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100)
  1964. }),
  1965. taxRate: item.taxRate || 13,
  1966. discount: item.discount || 0,
  1967. packageSpec: item.packageSpec || '',
  1968. packageSpecs: item.packageSpecs,
  1969. deliveryMethod: item.deliveryMethod || '',
  1970. deliveryDate: '',
  1971. description: item.description || '',
  1972. remark: item.remark || '',
  1973. sort: item.sort,
  1974. brandMode: item.brandMode || undefined,
  1975. brandId: item.brandId || undefined,
  1976. brandName: item.brandName || undefined,
  1977. brandOptions: [],
  1978. priceTiers: isFw ? [] : undefined
  1979. }
  1980. })
  1981. form.value.products.forEach((p) => normalizeContractProductPackageFields(p))
  1982. await enrichContractProductBrandMeta(form.value.products)
  1983. form.value.products.forEach((p) => calculateProductAmount(p))
  1984. calculateTotalAmount()
  1985. })
  1986. .catch(() => {})
  1987. }
  1988. }
  1989. }
  1990. /** 加载用户列表(负责人下拉) */
  1991. const loadUserList = async () => {
  1992. try {
  1993. const res = await fetchUserOptions()
  1994. const options = (res as any)?.data ?? res ?? []
  1995. userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
  1996. userId: item.value ?? item.userId ?? item.id,
  1997. nickName: item.label ?? item.nickName ?? item.userName ?? ''
  1998. }))
  1999. } catch (error) {
  2000. console.error('加载用户列表失败:', error)
  2001. userList.value = []
  2002. }
  2003. }
  2004. /** 新增时默认负责人为方金强 */
  2005. const applyDefaultOwnerUser = () => {
  2006. const matched = findDefaultOwnerUser(ownerUserList.value)
  2007. if (!matched) return
  2008. form.value.ownerUserId = matched.userId as ContractForm['ownerUserId']
  2009. form.value.ownerUserName = matched.nickName || ''
  2010. }
  2011. /** 负责人变化 */
  2012. const handleOwnerChange = (userId: string | number) => {
  2013. const user = ownerUserList.value.find((item: any) => String(item.userId) === String(userId))
  2014. form.value.ownerUserName = user?.nickName || ''
  2015. }
  2016. /** 选择负责人 */
  2017. const handleSelectUser = () => {
  2018. userSelectorVisible.value = true
  2019. }
  2020. /** 用户选择确认 */
  2021. const handleUserConfirm = (user: UserVO) => {
  2022. if (user) {
  2023. form.value.ownerUserId = user.userId
  2024. form.value.ownerUserName = user.nickName
  2025. }
  2026. }
  2027. /** 供方主体变化处理 */
  2028. const handleSupplierChange = async (supplierId: number | undefined) => {
  2029. if (!supplierId) {
  2030. if (isPartiesSwapped.value) {
  2031. form.value.partyB = ''
  2032. form.value.partyBAddress = ''
  2033. form.value.partyBLegalPerson = ''
  2034. form.value.partyBAgent = ''
  2035. form.value.partyBPhoneFax = ''
  2036. form.value.partyBBank = ''
  2037. form.value.partyBAccount = ''
  2038. } else {
  2039. form.value.partyA = ''
  2040. form.value.partyAAddress = ''
  2041. form.value.partyALegalPerson = ''
  2042. form.value.partyAAgent = ''
  2043. form.value.partyAPhoneFax = ''
  2044. form.value.partyABank = ''
  2045. form.value.partyAAccount = ''
  2046. }
  2047. return
  2048. }
  2049. const supplier = supplierList.value.find((s) => s.id === supplierId)
  2050. if (supplier) {
  2051. if (isPartiesSwapped.value) {
  2052. form.value.partyB = supplier.supplierName || ''
  2053. form.value.partyBAddress = supplier.supplierAddress || ''
  2054. form.value.partyBLegalPerson = supplier.legalPerson || ''
  2055. form.value.partyBAgent = supplier.agent || ''
  2056. form.value.partyBPhoneFax = supplier.phoneFax || ''
  2057. form.value.partyBBank = supplier.bankName || ''
  2058. form.value.partyBAccount = supplier.bankAccount || ''
  2059. } else {
  2060. form.value.partyA = supplier.supplierName || ''
  2061. form.value.partyAAddress = supplier.supplierAddress || ''
  2062. form.value.partyALegalPerson = supplier.legalPerson || ''
  2063. form.value.partyAAgent = supplier.agent || ''
  2064. form.value.partyAPhoneFax = supplier.phoneFax || ''
  2065. form.value.partyABank = supplier.bankName || ''
  2066. form.value.partyAAccount = supplier.bankAccount || ''
  2067. }
  2068. updateContractNoBySupplier(supplier.supplierCode)
  2069. rebuildContractName()
  2070. const allowToxic = Number((supplier as any).allowToxic || 0)
  2071. const allowExplosive = Number((supplier as any).allowExplosive || 0)
  2072. const { kept, removedNames } = removeProductsNotAllowedByHazard(
  2073. form.value.products || [],
  2074. allowToxic,
  2075. allowExplosive
  2076. )
  2077. form.value.products = kept
  2078. if (removedNames.length) {
  2079. $modal.msgWarning(`当前主体不支持以下产品,已自动移除:${removedNames.join('、')}`)
  2080. form.value.products.forEach((p) => calculateProductAmount(p))
  2081. calculateTotalAmount()
  2082. }
  2083. }
  2084. }
  2085. /** 更新合同编号中的供方编码 */
  2086. const updateContractNoBySupplier = (newSupplierCode: string | undefined) => {
  2087. if (!form.value.contractNo || !newSupplierCode) return
  2088. const contractNoPattern = /^([A-Z]+)\/X-JT(\d{8})(\d{3})$/
  2089. const match = form.value.contractNo.match(contractNoPattern)
  2090. if (match) {
  2091. const date = match[2]
  2092. const serialNum = match[3]
  2093. form.value.contractNo = `${newSupplierCode}/X-JT${date}${serialNum}`
  2094. } else {
  2095. const index = form.value.contractNo.indexOf('/X-JT')
  2096. if (index > 0) {
  2097. const oldSupplierCode = form.value.contractNo.substring(0, index)
  2098. form.value.contractNo = form.value.contractNo.replace(oldSupplierCode, newSupplierCode)
  2099. }
  2100. }
  2101. }
  2102. const CONTRACT_NO_STD_RE = /^([A-Z]+)\/X-JT(\d{8})(\d{3})$/
  2103. /** 签订日变更时:标准编号只换日期段,保留序号;并同步名称括号内编号 */
  2104. const syncContractNoDateFromSignDate = (signDateVal: string | null) => {
  2105. if (!signDateVal || !form.value.contractNo) return
  2106. const m = String(form.value.contractNo).match(CONTRACT_NO_STD_RE)
  2107. if (!m) return
  2108. const dateStr = signDateVal.replace(/-/g, '')
  2109. if (dateStr.length !== 8) return
  2110. if (m[2] === dateStr) return
  2111. const nextNo = `${m[1]}/X-JT${dateStr}${m[3]}`
  2112. form.value.contractNo = nextNo
  2113. rebuildContractName()
  2114. }
  2115. /** 面板单位全称 → 匹配交易主体/客户主数据取简称;对不上用全称 */
  2116. const resolvePartyShortName = (partyName?: string): string => {
  2117. const party = (partyName || '').trim()
  2118. if (!party) return ''
  2119. const supplier = form.value.supplierId ? supplierList.value.find((s) => s.id === form.value.supplierId) : undefined
  2120. const supplierName = (supplier?.supplierName || '').trim()
  2121. const supplierShort = (supplier?.shortName || supplierName || '').trim()
  2122. if (supplierName && party === supplierName) return supplierShort || party
  2123. if (supplierShort && party === supplierShort) return supplierShort
  2124. const customerName = (form.value.customerName || '').trim()
  2125. const customerShort = (customerShortName.value || '').trim()
  2126. const invoiceName = (customerInvoiceName.value || '').trim()
  2127. if (customerName && party === customerName) return customerShort || customerName
  2128. if (customerShort && party === customerShort) return customerShort
  2129. if (invoiceName && party === invoiceName) return customerShort || invoiceName
  2130. return party
  2131. }
  2132. /** 重建合同名称:始终 {供方简称}-{需方简称}({合同编号}),随面板调换 */
  2133. const rebuildContractName = () => {
  2134. const partyAShort = resolvePartyShortName(form.value.partyA)
  2135. const partyBShort = resolvePartyShortName(form.value.partyB)
  2136. const nameBody = [partyAShort, partyBShort].filter(Boolean).join('-')
  2137. if (!nameBody) return
  2138. const contractNo = (form.value.contractNo || '').trim()
  2139. form.value.contractName = contractNo ? `${nameBody}(${contractNo})` : nameBody
  2140. }
  2141. /** 根据面板与交易主体/客户推断是否已调换(编辑回显用) */
  2142. const syncPartiesSwappedFlagFromPanels = () => {
  2143. const partyA = (form.value.partyA || '').trim()
  2144. const partyB = (form.value.partyB || '').trim()
  2145. if (!partyA && !partyB) {
  2146. isPartiesSwapped.value = false
  2147. return
  2148. }
  2149. const supplier = form.value.supplierId
  2150. ? supplierList.value.find((s) => s.id === form.value.supplierId)
  2151. : undefined
  2152. const supplierName = (supplier?.supplierName || '').trim()
  2153. if (supplierName) {
  2154. if (partyB === supplierName && partyA !== supplierName) {
  2155. isPartiesSwapped.value = true
  2156. return
  2157. }
  2158. if (partyA === supplierName) {
  2159. isPartiesSwapped.value = false
  2160. return
  2161. }
  2162. }
  2163. const customerName = (form.value.customerName || '').trim()
  2164. const invoiceName = (customerInvoiceName.value || '').trim()
  2165. if ((customerName && partyA === customerName) || (invoiceName && partyA === invoiceName)) {
  2166. isPartiesSwapped.value = true
  2167. return
  2168. }
  2169. if ((customerName && partyB === customerName) || (invoiceName && partyB === invoiceName)) {
  2170. isPartiesSwapped.value = false
  2171. }
  2172. }
  2173. /** 供需方调换:先快照 → 清空两侧 → nextTick 后交叉写回;勿调 handleSupplierChange */
  2174. const handleSwapParties = async () => {
  2175. const { nextPartyA, nextPartyB } = preparePartySwap(form.value as Record<string, any>)
  2176. clearPartyFields(form.value as Record<string, any>)
  2177. partiesSwapEpoch.value += 1
  2178. await nextTick()
  2179. applyPartySwapSnapshots(form.value as Record<string, any>, nextPartyA, nextPartyB)
  2180. isPartiesSwapped.value = !isPartiesSwapped.value
  2181. rebuildContractName()
  2182. $modal.msgSuccess('供需方信息已调换')
  2183. }
  2184. /** 合同类型变化处理 */
  2185. const handleContractTypeChange = (value: string) => {
  2186. if (isFrameworkContractType(value)) {
  2187. form.value.products?.forEach((p) => {
  2188. applyFrameworkDefaultsToProduct(p)
  2189. if (!p.priceTiers) {
  2190. p.priceTiers = []
  2191. }
  2192. if (!isFrameworkPriceMarket(p)) {
  2193. calculateProductAmount(p)
  2194. }
  2195. })
  2196. calculateTotalAmount()
  2197. } else {
  2198. form.value.products?.forEach((p) => {
  2199. normalizeFrameworkProductPricing(p, false)
  2200. calculateProductAmount(p)
  2201. })
  2202. calculateTotalAmount()
  2203. }
  2204. if (isPurchaseLikeContractType(value) && !isPartiesSwapped.value) {
  2205. $modal
  2206. .confirm('检测到您选择了采购类合同,系统将自动调换供需方信息,是否继续?')
  2207. .then(async () => {
  2208. await handleSwapParties()
  2209. })
  2210. .catch(() => {
  2211. rebuildContractName()
  2212. })
  2213. } else {
  2214. rebuildContractName()
  2215. }
  2216. }
  2217. /** 合同模版变化处理 */
  2218. const handleContractTemplateChange = (value: string) => {
  2219. if (value === 'customer_template') {
  2220. ElMessage({
  2221. message: '使用客户模版时,必须上传合同附件!',
  2222. type: 'warning',
  2223. duration: 3000
  2224. })
  2225. }
  2226. }
  2227. /** 合同期限日期范围变化 */
  2228. const handleDateRangeChange = (val: [string, string] | null) => {
  2229. if (val && val.length === 2) {
  2230. form.value.startDate = val[0]
  2231. form.value.endDate = val[1]
  2232. } else {
  2233. form.value.startDate = ''
  2234. form.value.endDate = ''
  2235. contractDateRange.value = [] as any
  2236. }
  2237. nextTick(() => {
  2238. formRef.value?.validateField('startDate')
  2239. })
  2240. }
  2241. /** 签订日期变化处理 */
  2242. const handleSignDateChange = (val: string | null) => {
  2243. if (val) {
  2244. const signDate = new Date(val)
  2245. const endDate = new Date(signDate)
  2246. endDate.setDate(endDate.getDate() + 30)
  2247. const formatDate = (date: Date): string => {
  2248. const year = date.getFullYear()
  2249. const month = String(date.getMonth() + 1).padStart(2, '0')
  2250. const day = String(date.getDate()).padStart(2, '0')
  2251. return `${year}-${month}-${day}`
  2252. }
  2253. const startDateStr = formatDate(signDate)
  2254. const endDateStr = formatDate(endDate)
  2255. contractDateRange.value = [startDateStr, endDateStr]
  2256. form.value.startDate = startDateStr
  2257. form.value.endDate = endDateStr
  2258. } else {
  2259. contractDateRange.value = [] as any
  2260. form.value.startDate = ''
  2261. form.value.endDate = ''
  2262. }
  2263. syncContractNoDateFromSignDate(val)
  2264. nextTick(() => {
  2265. formRef.value?.validateField('startDate')
  2266. })
  2267. }
  2268. /** 添加产品 */
  2269. const handleAddProduct = () => {
  2270. if (!form.value.supplierId) {
  2271. $modal.msgWarning('请先选择交易主体')
  2272. return
  2273. }
  2274. productSelectorVisible.value = true
  2275. }
  2276. /** 产品选择确认 */
  2277. const handleProductSelected = (products: any[]) => {
  2278. const isFw = isFrameworkContractType(form.value.contractType)
  2279. products.forEach((product) => {
  2280. const brandMeta = initLineBrandMetaFromProduct(product)
  2281. form.value.products.push({
  2282. productId: product.id,
  2283. productName: product.productName,
  2284. productModel: product.model || '',
  2285. categoryId: product.categoryId,
  2286. categoryName: product.categoryName || '',
  2287. unit: product.unit || '个',
  2288. ...(isFw ? createFrameworkProductPricingDefaults() : { quantity: 1, price: product.price || 0, taxAmount: 0, subtotal: product.price || 0 }),
  2289. taxRate: normalizeTaxRateDisplay(product.taxRate, 13),
  2290. packageSpecs: [],
  2291. packageSpec: '',
  2292. deliveryMethod: '',
  2293. description: product.description || '',
  2294. remark: '',
  2295. isToxic: Number(product.isToxic || 0),
  2296. isExplosive: Number(product.isExplosive || 0),
  2297. ...brandMeta,
  2298. priceTiers: isFw ? [] : undefined
  2299. })
  2300. })
  2301. form.value.products.forEach((p) => {
  2302. calculateProductAmount(p)
  2303. })
  2304. calculateTotalAmount()
  2305. }
  2306. const handleBrandIdChange = (row: ExtendedContractProductForm, val: string | undefined | null) => {
  2307. applyBrandSelectChange(row, val, row.brandOptions || [])
  2308. }
  2309. const aliasBrandManageVisible = ref(false)
  2310. const aliasBrandManageProductId = ref('')
  2311. const aliasBrandManageProductName = ref('')
  2312. const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
  2313. function openAliasBrandManage(row: ExtendedContractProductForm, mode: 'alias' | 'brand') {
  2314. if (!row.productId) return
  2315. aliasBrandManageProductId.value = String(row.productId)
  2316. aliasBrandManageProductName.value = String(
  2317. row.standardProductName || row.productName || ''
  2318. )
  2319. aliasBrandManageMode.value = mode
  2320. aliasBrandManageVisible.value = true
  2321. }
  2322. function handleAliasBrandManaged(product: Record<string, any>) {
  2323. applyManagedProductMetaToLines(
  2324. form.value.products || [],
  2325. aliasBrandManageProductId.value,
  2326. product
  2327. )
  2328. }
  2329. async function enrichContractProductBrandMeta(lines: ExtendedContractProductForm[]) {
  2330. await Promise.all(
  2331. (lines || []).map(async (line) => {
  2332. if (!line.productId) return
  2333. try {
  2334. const res: any = await fetchProductDetail({ id: String(line.productId) })
  2335. const data = res?.data ?? res
  2336. if (!data) return
  2337. const meta = initLineBrandMetaFromProduct(data)
  2338. line.standardProductName = meta.standardProductName
  2339. line.aliasOptions = meta.aliasOptions
  2340. line.aliasName = resolveAliasFromSavedName(
  2341. line.productName,
  2342. meta.standardProductName,
  2343. meta.aliasOptions
  2344. )
  2345. line.brandOptions = meta.brandOptions
  2346. if (meta.brandOptions.length) {
  2347. if (line.brandMode === 'required' && line.brandId) {
  2348. applyBrandSelectChange(line, line.brandId, meta.brandOptions)
  2349. } else {
  2350. line.brandMode = 'any'
  2351. line.brandId = undefined
  2352. line.brandName = undefined
  2353. }
  2354. } else {
  2355. line.brandMode = undefined
  2356. line.brandId = undefined
  2357. line.brandName = undefined
  2358. }
  2359. } catch (e) {
  2360. console.warn('enrich contract brand meta failed', line.productId, e)
  2361. }
  2362. })
  2363. )
  2364. }
  2365. /** 规范化税率展示 */
  2366. const normalizeTaxRateDisplay = (value: any, defaultValue = 0): number => {
  2367. if (value === null || value === undefined || value === '') {
  2368. return defaultValue
  2369. }
  2370. const num = Number(value)
  2371. if (Number.isNaN(num)) {
  2372. return defaultValue
  2373. }
  2374. return Number.isInteger(num) ? num : parseFloat(num.toString())
  2375. }
  2376. /** 产品选择变化 */
  2377. const handleProductSelectionChange = (selection: ExtendedContractProductForm[]) => {
  2378. selectedProducts.value = selection
  2379. }
  2380. /** 删除选中的产品 */
  2381. const handleRemoveSelectedProducts = () => {
  2382. if (selectedProducts.value.length === 0) return
  2383. $modal
  2384. .confirm(`确认删除选中的 ${selectedProducts.value.length} 个产品吗?`)
  2385. .then(() => {
  2386. selectedProducts.value.forEach((selectedProduct) => {
  2387. const index = form.value.products.findIndex((p) => p === selectedProduct)
  2388. if (index > -1) {
  2389. form.value.products.splice(index, 1)
  2390. }
  2391. })
  2392. selectedProducts.value = []
  2393. calculateTotalAmount()
  2394. $modal.msgSuccess('删除成功')
  2395. })
  2396. .catch(() => {})
  2397. }
  2398. /** 删除产品 */
  2399. const handleRemoveProduct = (index: number) => {
  2400. form.value.products.splice(index, 1)
  2401. calculateTotalAmount()
  2402. }
  2403. /** 单价变化 */
  2404. const handlePriceChange = (row: ExtendedContractProductForm) => {
  2405. calculateProductAmount(row)
  2406. calculateTotalAmount()
  2407. }
  2408. /** 数量变化 */
  2409. const handleQuantityChange = (row: ExtendedContractProductForm) => {
  2410. calculateProductAmount(row)
  2411. calculateTotalAmount()
  2412. }
  2413. /** 税率变化 */
  2414. const handleTaxRateChange = (row: ExtendedContractProductForm) => {
  2415. calculateProductAmount(row)
  2416. calculateTotalAmount()
  2417. }
  2418. const handleEnableFrameworkQuantityFixed = (row: ExtendedContractProductForm) => {
  2419. enableFrameworkQuantityFixed(row)
  2420. calculateProductAmount(row)
  2421. calculateTotalAmount()
  2422. }
  2423. const handleResetFrameworkQuantity = (row: ExtendedContractProductForm) => {
  2424. resetFrameworkQuantityToCustomerDemand(row)
  2425. calculateProductAmount(row)
  2426. calculateTotalAmount()
  2427. }
  2428. const handleEnableFrameworkPriceFixed = (row: ExtendedContractProductForm) => {
  2429. enableFrameworkPriceFixed(row)
  2430. calculateProductAmount(row)
  2431. calculateTotalAmount()
  2432. }
  2433. const handleResetFrameworkPrice = (row: ExtendedContractProductForm) => {
  2434. resetFrameworkPriceToMarket(row)
  2435. calculateTotalAmount()
  2436. }
  2437. /** 计算产品金额 */
  2438. const calculateProductAmount = (product: ExtendedContractProductForm) => {
  2439. if (isFrameworkContractType(form.value.contractType) && isFrameworkPriceMarket(product)) {
  2440. product.price = 0
  2441. product.subtotal = 0
  2442. product.taxAmount = 0
  2443. return
  2444. }
  2445. const price = parseFloat(product.price?.toString() || '0')
  2446. const quantity = parseFloat(product.quantity?.toString() || '0')
  2447. const taxRate = parseFloat(product.taxRate?.toString() || '0')
  2448. const subtotal = Math.round(price * quantity * 100) / 100
  2449. product.subtotal = subtotal
  2450. const taxRateDecimal = taxRate / 100
  2451. let taxAmount = 0
  2452. if (taxRate > 0 && subtotal > 0) {
  2453. const taxBase = subtotal / (1 + taxRateDecimal)
  2454. taxAmount = Math.round(taxBase * taxRateDecimal * 100) / 100
  2455. }
  2456. product.taxAmount = taxAmount
  2457. }
  2458. /** 计算合计金额(含框架合同:按需/随行就市行为 0,有小计则累加) */
  2459. const calculateTotalAmount = () => {
  2460. let total = 0
  2461. form.value.products.forEach((item) => {
  2462. const quantity = item.quantity || 0
  2463. const price = item.price || 0
  2464. const discount = item.discount || 0
  2465. const subtotal = quantity * price * (1 - discount / 100)
  2466. ;(item as ExtendedContractProductForm).subtotal = subtotal
  2467. total += subtotal
  2468. })
  2469. form.value.amount = total
  2470. }
  2471. /** 框架合同调价函:逗号分隔 URL 与 letterOssIds 互转 */
  2472. const tierLetterCsv = (row: PriceTierRow) => (row.letterOssIds || []).join(',')
  2473. const syncTierLetterCsv = (row: PriceTierRow, val: string) => {
  2474. row.letterOssIds = String(val || '')
  2475. .split(',')
  2476. .map((item) => item.trim())
  2477. .filter(Boolean)
  2478. }
  2479. /** 组装提交/保存的产品明细 */
  2480. const buildProductsPayload = () => {
  2481. const list = form.value.products || []
  2482. list.forEach((product) => prepareLineAliasAndBrandForSubmit(product))
  2483. return list.map((product: any, idx: number) => {
  2484. const base: any = {
  2485. contractId: product.contractId || form.value.id,
  2486. productId: product.productId,
  2487. productName: product.productName,
  2488. productNo: product.productNo,
  2489. categoryId: product.categoryId,
  2490. categoryName: product.categoryName,
  2491. model: product.productModel || product.model,
  2492. unit: product.unit,
  2493. quantity: product.quantity,
  2494. price: product.price,
  2495. discount: product.discount,
  2496. taxRate: product.taxRate,
  2497. taxAmount: product.taxAmount,
  2498. subtotal: product.subtotal,
  2499. deliveryDate: product.deliveryDate,
  2500. description: product.description,
  2501. ...buildPackageSpecsPayload(product),
  2502. deliveryMethod: product.deliveryMethod,
  2503. sort: product.sort ?? idx + 1,
  2504. remark: product.remark,
  2505. brandMode: product.brandMode || null,
  2506. brandId: product.brandMode === 'required' ? product.brandId || null : null,
  2507. brandName: product.brandMode === 'required' ? product.brandName || null : null
  2508. }
  2509. if (product.id) {
  2510. base.id = product.id
  2511. }
  2512. if (isFrameworkContractType(form.value.contractType)) {
  2513. const fwPricing = prepareFrameworkProductForSubmit(product)
  2514. Object.assign(base, fwPricing)
  2515. base.priceTiers = (product.priceTiers || [])
  2516. .filter(
  2517. (t: any) => t.startDate && t.endDate && t.price != null && t.price !== '' && Array.isArray(t.letterOssIds) && t.letterOssIds.length > 0
  2518. )
  2519. .map((t: any, i: number) => ({
  2520. id: t.id,
  2521. startDate: t.startDate,
  2522. endDate: t.endDate,
  2523. price: Number(t.price),
  2524. letterOssIds: (t.letterOssIds || []).map((id: string) => String(id).trim()).filter(Boolean),
  2525. sort: t.sort ?? i + 1
  2526. }))
  2527. }
  2528. return base
  2529. })
  2530. }
  2531. const addPriceTierRow = (pIdx: number) => {
  2532. const p = form.value.products[pIdx]
  2533. if (!p) return
  2534. if (!p.priceTiers) {
  2535. p.priceTiers = []
  2536. }
  2537. p.priceTiers.push({ startDate: '', endDate: '', price: undefined, letterOssIds: [] })
  2538. }
  2539. const removePriceTierRow = (pIdx: number, tIdx: number) => {
  2540. const p = form.value.products[pIdx]
  2541. if (!p?.priceTiers) return
  2542. p.priceTiers.splice(tIdx, 1)
  2543. }
  2544. /** 处理附件数据 */
  2545. const handleAttachmentData = async () => {
  2546. attachmentList.value = buildUploadFileListFromField(
  2547. form.value.attachmentFiles
  2548. ) as ContractAttachmentItem[]
  2549. }
  2550. /** 保存草稿 */
  2551. const handleSaveDraft = async () => {
  2552. if (!form.value.customerId) {
  2553. $modal.msgWarning('客户名称不能为空')
  2554. return
  2555. }
  2556. if (!form.value.supplierId) {
  2557. $modal.msgWarning('交易主体不能为空')
  2558. return
  2559. }
  2560. const confirmedZero = await confirmZeroPricingIfNeeded(
  2561. form.value.products,
  2562. isFrameworkContractType(form.value.contractType)
  2563. )
  2564. if (!confirmedZero) return
  2565. rebuildContractName()
  2566. saveDraftLoading.value = true
  2567. try {
  2568. // 以界面列表为准(含空列表),避免 form 残留字段把上一份合同附件写回
  2569. const attachmentJson = serializeContractAttachments(attachmentList.value)
  2570. const data: any = { ...form.value }
  2571. data.status = 'draft'
  2572. data.approvalStatus = null
  2573. data.attachmentFiles = attachmentJson
  2574. omitEmptyExceptPartyFields(data)
  2575. if (data.contactsName) {
  2576. data.contactName = data.contactsName
  2577. }
  2578. if (data.contactsPhone) {
  2579. data.contactMobile = data.contactsPhone
  2580. }
  2581. delete data.customerName
  2582. delete data.opportunityName
  2583. delete data.quoteNo
  2584. delete data.contactsName
  2585. delete data.contactsPhone
  2586. delete data.ownerUserName
  2587. delete data.templateContent
  2588. if (data.products && Array.isArray(data.products) && data.products.length > 0) {
  2589. ;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
  2590. for (let i = 0; i < form.value.products.length; i++) {
  2591. const product = form.value.products[i]
  2592. const rowNum = i + 1
  2593. if (!product.productId || String(product.productId).trim() === '') {
  2594. $modal.msgWarning(`第${rowNum}行请从产品库选择产品`)
  2595. return
  2596. }
  2597. if (!product.productName || product.productName.trim() === '') {
  2598. $modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`)
  2599. return
  2600. }
  2601. }
  2602. data.products = buildProductsPayload()
  2603. } else {
  2604. delete data.products
  2605. }
  2606. if (form.value.id) {
  2607. await updateContract(data)
  2608. $modal.msgSuccess('保存成功')
  2609. } else {
  2610. await addContract(data)
  2611. $modal.msgSuccess('保存成功')
  2612. }
  2613. handleClose()
  2614. emit('success')
  2615. } finally {
  2616. saveDraftLoading.value = false
  2617. }
  2618. }
  2619. /** 提交审批 */
  2620. const handleSubmit = async () => {
  2621. try {
  2622. await formRef.value?.validate()
  2623. } catch (error) {
  2624. expandPartiesForInvalidFields(error as Record<string, unknown>)
  2625. ElMessage.warning({
  2626. message: '请填写完整的必填信息',
  2627. duration: 3000
  2628. })
  2629. return
  2630. }
  2631. if (form.value.contractTemplate === 'customer') {
  2632. const hasAttachments = attachmentList.value.some((file) => file.status === 'success' && file.ossId)
  2633. if (!hasAttachments) {
  2634. ElMessage.warning({
  2635. message: '使用客户模版时,必须上传合同附件',
  2636. duration: 3000
  2637. })
  2638. return
  2639. }
  2640. }
  2641. const requiresProducts = form.value.contractType === 'sale' || form.value.contractType === 'purchase' || isFrameworkContractType(form.value.contractType)
  2642. const validateProductRowCommon = (product: any, rowNum: number): boolean => {
  2643. if (!product.productId || String(product.productId).trim() === '') {
  2644. $modal.msgWarning(`第${rowNum}行请从产品库选择产品`)
  2645. return false
  2646. }
  2647. if (!product.productName || String(product.productName).trim() === '') {
  2648. $modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`)
  2649. return false
  2650. }
  2651. const isFwRow = isFrameworkContractType(form.value.contractType)
  2652. if (isFwRow) {
  2653. if (product.quantityMode === 'fixed') {
  2654. if (isBlankNumeric(product.quantity)) {
  2655. $modal.msgWarning(`第${rowNum}行产品的数量不能为空`)
  2656. return false
  2657. }
  2658. if (isNegativeNumeric(product.quantity)) {
  2659. $modal.msgWarning(`第${rowNum}行产品的数量不能为负数`)
  2660. return false
  2661. }
  2662. }
  2663. } else if (isBlankNumeric(product.quantity)) {
  2664. $modal.msgWarning(`第${rowNum}行产品的数量不能为空`)
  2665. return false
  2666. } else if (isNegativeNumeric(product.quantity)) {
  2667. $modal.msgWarning(`第${rowNum}行产品的数量不能为负数`)
  2668. return false
  2669. }
  2670. if (!product.deliveryMethod || String(product.deliveryMethod).trim() === '') {
  2671. $modal.msgWarning(`第${rowNum}行产品的送货方式不能为空`)
  2672. return false
  2673. }
  2674. return true
  2675. }
  2676. if (requiresProducts) {
  2677. if (!form.value.products || form.value.products.length === 0) {
  2678. $modal.msgWarning('销售合同、采购合同或框架合同必须至少添加一个产品')
  2679. return
  2680. }
  2681. const packageCheck = validateContractProductPackageSpecs(form.value.products)
  2682. if (!packageCheck.ok) {
  2683. $modal.msgWarning(packageCheck.message)
  2684. return
  2685. }
  2686. ;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
  2687. for (let i = 0; i < form.value.products.length; i++) {
  2688. const product = form.value.products[i]
  2689. const rowNum = i + 1
  2690. if (!validateProductRowCommon(product, rowNum)) return
  2691. const isFw = isFrameworkContractType(form.value.contractType)
  2692. if (!isFw) {
  2693. if (isBlankNumeric(product.price)) {
  2694. $modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
  2695. return
  2696. }
  2697. if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
  2698. $modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
  2699. return
  2700. }
  2701. } else {
  2702. if (product.priceMode === 'fixed') {
  2703. if (isBlankNumeric(product.price)) {
  2704. $modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
  2705. return
  2706. }
  2707. if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
  2708. $modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
  2709. return
  2710. }
  2711. }
  2712. const tiers = product.priceTiers || []
  2713. for (let j = 0; j < tiers.length; j++) {
  2714. const t = tiers[j]
  2715. const tRow = j + 1
  2716. if (!t.startDate || !t.endDate) {
  2717. $modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:请选择开始与结束日期`)
  2718. return
  2719. }
  2720. if (String(t.startDate) > String(t.endDate)) {
  2721. $modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:开始日期不能晚于结束日期`)
  2722. return
  2723. }
  2724. const pVal = t.price === null || t.price === '' ? NaN : Number(t.price)
  2725. if (Number.isNaN(pVal) || pVal <= 0) {
  2726. $modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:含税单价必须大于0`)
  2727. return
  2728. }
  2729. if (!(t.letterOssIds?.length > 0)) {
  2730. $modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:请上传调价函`)
  2731. return
  2732. }
  2733. }
  2734. }
  2735. }
  2736. } else if (form.value.products && form.value.products.length > 0) {
  2737. ;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
  2738. for (let i = 0; i < form.value.products.length; i++) {
  2739. const product = form.value.products[i]
  2740. const rowNum = i + 1
  2741. if (!validateProductRowCommon(product, rowNum)) return
  2742. if (isBlankNumeric(product.price)) {
  2743. $modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
  2744. return
  2745. }
  2746. if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
  2747. $modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
  2748. return
  2749. }
  2750. }
  2751. }
  2752. const confirmedZero = await confirmZeroPricingIfNeeded(
  2753. form.value.products,
  2754. isFrameworkContractType(form.value.contractType)
  2755. )
  2756. if (!confirmedZero) return
  2757. submitLoading.value = true
  2758. try {
  2759. rebuildContractName()
  2760. // 以界面列表为准(含空列表),避免 form 残留字段把上一份合同附件写回
  2761. const attachmentJson = serializeContractAttachments(attachmentList.value)
  2762. const data: any = { ...form.value }
  2763. data.attachmentFiles = attachmentJson
  2764. omitEmptyExceptPartyFields(data)
  2765. if (data.contactsName) {
  2766. data.contactName = data.contactsName
  2767. }
  2768. if (data.contactsPhone) {
  2769. data.contactMobile = data.contactsPhone
  2770. }
  2771. delete data.customerName
  2772. delete data.opportunityName
  2773. delete data.quoteNo
  2774. delete data.contactsName
  2775. delete data.contactsPhone
  2776. delete data.ownerUserName
  2777. delete data.templateContent
  2778. if (data.products && Array.isArray(data.products) && data.products.length > 0) {
  2779. data.products = buildProductsPayload()
  2780. } else {
  2781. delete data.products
  2782. }
  2783. let contractId: string | number
  2784. if (form.value.id) {
  2785. await updateContract(data)
  2786. contractId = form.value.id
  2787. $modal.msgSuccess('保存成功')
  2788. } else {
  2789. const res = await addContract(data)
  2790. contractId = res.data
  2791. if (contractId == null || contractId === '') {
  2792. $modal.msgError('保存成功但未返回合同 ID,请从列表重新提交审批')
  2793. emit('success')
  2794. return
  2795. }
  2796. form.value.id = contractId as any
  2797. $modal.msgSuccess('保存成功')
  2798. }
  2799. approvalDialogRef.value?.open(contractId, 'submit')
  2800. } finally {
  2801. submitLoading.value = false
  2802. }
  2803. }
  2804. /** 审批成功回调 */
  2805. const handleApprovalSuccess = () => {
  2806. handleClose()
  2807. emit('success')
  2808. }
  2809. /** 关闭对话框 */
  2810. const handleClose = () => {
  2811. dialogVisible.value = false
  2812. }
  2813. /** 加载供方信息列表 */
  2814. const loadSupplierList = async () => {
  2815. try {
  2816. const res = await listAllSupplierInfo({ status: '0' })
  2817. supplierList.value = res.data || []
  2818. return res.data || []
  2819. } catch (error) {
  2820. console.error('加载供方信息列表失败:', error)
  2821. return []
  2822. }
  2823. }
  2824. </script>
  2825. <style scoped lang="scss">
  2826. .contract-form-dialog {
  2827. :deep(.el-dialog__wrapper) {
  2828. padding: 0 !important;
  2829. top: 20px !important;
  2830. height: calc(100vh - 40px) !important;
  2831. }
  2832. :deep(.el-dialog) {
  2833. backdrop-filter: none;
  2834. height: 100% !important;
  2835. max-height: calc(100vh - 40px);
  2836. display: flex;
  2837. flex-direction: column;
  2838. }
  2839. &.contract-form-dialog--page {
  2840. :deep(.el-dialog__wrapper) {
  2841. top: 0 !important;
  2842. height: 100% !important;
  2843. }
  2844. :deep(.el-dialog) {
  2845. max-height: 100%;
  2846. border-radius: 0;
  2847. box-shadow: none;
  2848. }
  2849. :deep(.el-dialog__header) {
  2850. display: none;
  2851. }
  2852. :deep(.el-dialog__body) {
  2853. padding: 8px 12px 16px;
  2854. }
  2855. :deep(.el-dialog__footer) {
  2856. padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
  2857. border-top: 1px solid #e2e8f0;
  2858. }
  2859. }
  2860. :deep(.dialog-footer) {
  2861. display: flex;
  2862. justify-content: center;
  2863. align-items: center;
  2864. gap: 16px;
  2865. width: 100%;
  2866. }
  2867. :deep(.el-dialog__header),
  2868. :deep(.el-dialog__footer) {
  2869. flex: 0 0 auto;
  2870. }
  2871. :deep(.el-dialog__body) {
  2872. padding-top: 8px;
  2873. flex: 1 1 auto;
  2874. min-height: 0;
  2875. max-height: none;
  2876. overflow-y: auto;
  2877. overflow-x: hidden;
  2878. }
  2879. }
  2880. .contract-form-body {
  2881. position: relative;
  2882. min-height: 280px;
  2883. padding: 0 4px;
  2884. }
  2885. .crm-dialog-form--page {
  2886. .contract-basic-info {
  2887. grid-template-columns: 1fr;
  2888. }
  2889. :deep(.el-row) {
  2890. display: flex;
  2891. flex-direction: column;
  2892. }
  2893. :deep(.el-col) {
  2894. width: 100% !important;
  2895. max-width: 100% !important;
  2896. }
  2897. }
  2898. .contract-basic-info {
  2899. display: grid;
  2900. grid-template-columns: repeat(4, minmax(0, 1fr));
  2901. gap: 12px 14px;
  2902. margin-bottom: 20px;
  2903. :deep(.el-form-item) {
  2904. margin-bottom: 0;
  2905. }
  2906. :deep(.el-input-number) {
  2907. width: 100%;
  2908. }
  2909. }
  2910. @media (max-width: 1100px) {
  2911. .contract-basic-info {
  2912. grid-template-columns: repeat(2, minmax(0, 1fr));
  2913. }
  2914. }
  2915. @media (max-width: 560px) {
  2916. .contract-basic-info {
  2917. grid-template-columns: 1fr;
  2918. }
  2919. }
  2920. .parties-section {
  2921. margin: 8px 0 20px;
  2922. }
  2923. .parties-section-toolbar {
  2924. display: flex;
  2925. justify-content: flex-end;
  2926. align-items: center;
  2927. gap: 8px;
  2928. margin-bottom: 12px;
  2929. }
  2930. .parties-row {
  2931. display: flex;
  2932. align-items: stretch;
  2933. gap: 0;
  2934. min-height: 0;
  2935. }
  2936. .party-panel {
  2937. flex: 1 1 0;
  2938. min-width: 0;
  2939. padding: 16px;
  2940. border: 1px solid var(--border, #d9e8e2);
  2941. border-radius: 10px;
  2942. background: #fff;
  2943. &.is-collapsed {
  2944. padding: 12px 16px;
  2945. }
  2946. :deep(.el-form-item) {
  2947. margin-bottom: 0;
  2948. }
  2949. }
  2950. .party-panel__header {
  2951. display: flex;
  2952. align-items: center;
  2953. margin-bottom: 12px;
  2954. .party-panel.is-collapsed & {
  2955. margin-bottom: 8px;
  2956. }
  2957. }
  2958. .party-panel__title {
  2959. margin-bottom: 0;
  2960. padding-left: 10px;
  2961. font-size: 14px;
  2962. font-weight: 600;
  2963. color: var(--section-bar-title-color, var(--primary-dark, #059669));
  2964. line-height: 1.5;
  2965. border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
  2966. }
  2967. .party-panel__summary {
  2968. display: flex;
  2969. align-items: baseline;
  2970. gap: 8px;
  2971. min-width: 0;
  2972. padding: 2px 0 2px 10px;
  2973. }
  2974. .party-panel__summary-label {
  2975. flex-shrink: 0;
  2976. font-size: 12px;
  2977. color: var(--mint-text-muted, #64748b);
  2978. line-height: 1.5;
  2979. }
  2980. .party-panel__summary-value {
  2981. min-width: 0;
  2982. font-size: 14px;
  2983. font-weight: 500;
  2984. color: var(--mint-text, #0f172a);
  2985. line-height: 1.5;
  2986. overflow: hidden;
  2987. text-overflow: ellipsis;
  2988. white-space: nowrap;
  2989. &.is-empty {
  2990. font-weight: 400;
  2991. color: var(--mint-text-muted, #94a3b8);
  2992. }
  2993. }
  2994. .party-flow-bridge {
  2995. flex: 0 0 88px;
  2996. display: flex;
  2997. flex-direction: column;
  2998. align-items: center;
  2999. justify-content: center;
  3000. gap: 10px;
  3001. padding: 0 8px;
  3002. }
  3003. .party-flow-bridge__label {
  3004. font-size: 12px;
  3005. font-weight: 600;
  3006. color: var(--primary-dark, #168f45);
  3007. letter-spacing: 0.06em;
  3008. padding: 4px 10px;
  3009. border-radius: 999px;
  3010. background: #e6f8ed;
  3011. border: 1px solid #a8e0b8;
  3012. line-height: 1.2;
  3013. }
  3014. .party-flow-bridge__arrow {
  3015. display: flex;
  3016. align-items: center;
  3017. justify-content: center;
  3018. width: 44px;
  3019. height: 44px;
  3020. border-radius: 50%;
  3021. color: var(--primary-dark, #168f45);
  3022. background: linear-gradient(145deg, #e6f8ed 0%, #d4f0de 100%);
  3023. border: 1px solid #a8e0b8;
  3024. box-shadow: 0 2px 8px rgba(28, 168, 81, 0.14);
  3025. svg {
  3026. width: 22px;
  3027. height: 22px;
  3028. display: block;
  3029. }
  3030. }
  3031. @media (max-width: 1100px) {
  3032. .parties-row {
  3033. flex-direction: column;
  3034. min-height: 0;
  3035. }
  3036. .party-flow-bridge {
  3037. flex: 0 0 auto;
  3038. flex-direction: row;
  3039. gap: 12px;
  3040. padding: 12px 0;
  3041. }
  3042. .party-flow-bridge__arrow {
  3043. transform: rotate(90deg);
  3044. }
  3045. }
  3046. .total-section {
  3047. margin-top: 20px;
  3048. padding: 15px;
  3049. background-color: #f5f7fa;
  3050. border-radius: 4px;
  3051. }
  3052. .total-item {
  3053. display: flex;
  3054. justify-content: flex-end;
  3055. align-items: center;
  3056. margin-bottom: 10px;
  3057. font-size: 16px;
  3058. &:last-child {
  3059. margin-bottom: 0;
  3060. }
  3061. }
  3062. .total-label {
  3063. font-weight: bold;
  3064. margin-right: 10px;
  3065. }
  3066. .total-value {
  3067. font-size: 18px;
  3068. font-weight: bold;
  3069. }
  3070. .text-muted {
  3071. color: #1e293b;
  3072. }
  3073. :deep(.money-field-input.el-input-number .el-input__inner),
  3074. :deep(.money-field-input.el-input-number .el-input__wrapper input) {
  3075. color: #dc2626 !important;
  3076. -webkit-text-fill-color: #dc2626 !important;
  3077. font-weight: 600;
  3078. }
  3079. /* 统一表格内输入框和下拉框高度 */
  3080. :deep(.el-table .el-input__wrapper) {
  3081. height: 32px !important;
  3082. min-height: 32px !important;
  3083. }
  3084. :deep(.el-table .el-select__wrapper) {
  3085. height: 32px !important;
  3086. min-height: 32px !important;
  3087. }
  3088. :deep(.el-table .el-input--small .el-input__wrapper) {
  3089. height: 32px !important;
  3090. min-height: 32px !important;
  3091. }
  3092. :deep(.el-table .el-select--small .el-select__wrapper) {
  3093. height: 32px !important;
  3094. min-height: 32px !important;
  3095. }
  3096. /* 包装规格多选:随已选项增多增高行高,展示全部标签 */
  3097. :deep(.el-table .col-package-specs .cell) {
  3098. overflow: visible;
  3099. white-space: normal;
  3100. line-height: 1.4;
  3101. }
  3102. :deep(.el-table .package-specs-select.el-select .el-select__wrapper),
  3103. :deep(.el-table .package-specs-select.el-select--small .el-select__wrapper) {
  3104. height: auto !important;
  3105. min-height: 32px !important;
  3106. align-items: flex-start;
  3107. padding-top: 4px;
  3108. padding-bottom: 4px;
  3109. }
  3110. :deep(.el-table .package-specs-select .el-select__selection) {
  3111. flex-wrap: wrap;
  3112. gap: 4px;
  3113. }
  3114. /* 价格输入框红色文字 */
  3115. .price-input :deep(.el-input__inner) {
  3116. color: #e34d59;
  3117. }
  3118. /* 去除输入框后置按钮的边框和背景 */
  3119. :deep(.el-input-group__append) {
  3120. background: transparent !important;
  3121. border: none !important;
  3122. box-shadow: none !important;
  3123. }
  3124. :deep(.el-input-group__append .el-button) {
  3125. border: none !important;
  3126. background: transparent !important;
  3127. box-shadow: none !important;
  3128. padding: 0 15px !important;
  3129. }
  3130. :deep(.el-input-group__append .el-button:hover) {
  3131. background: transparent !important;
  3132. border: none !important;
  3133. }
  3134. :deep(.el-input-group__append .el-button:focus) {
  3135. background: transparent !important;
  3136. border: none !important;
  3137. }
  3138. /* 框架合同产品明细:默认文案 + 可切换填数字 */
  3139. .framework-pricing-cell {
  3140. display: flex;
  3141. flex-direction: column;
  3142. align-items: flex-start;
  3143. gap: 2px;
  3144. min-width: 0;
  3145. }
  3146. .framework-pricing-cell--money {
  3147. align-items: flex-end;
  3148. }
  3149. .framework-pricing-cell__label {
  3150. font-size: 13px;
  3151. line-height: 1.4;
  3152. white-space: nowrap;
  3153. }
  3154. .framework-pricing-cell__action {
  3155. padding: 0 !important;
  3156. height: auto !important;
  3157. font-size: 12px !important;
  3158. }
  3159. /* 框架合同调价函:表格内紧凑附件上传 */
  3160. .fw-price-tier-table :deep(.attachment-upload--compact:not(.attachment-upload--readonly)) {
  3161. min-width: 0;
  3162. .attachment-upload {
  3163. width: auto;
  3164. }
  3165. .attachment-list {
  3166. flex-wrap: wrap;
  3167. align-items: flex-start;
  3168. }
  3169. }
  3170. /* 框架合同:增加「调价函」行内按钮可点区域宽度 */
  3171. .fw-tier-letter-add-btn.el-button--primary.is-link {
  3172. min-width: 108px;
  3173. padding-left: 12px;
  3174. padding-right: 12px;
  3175. justify-content: center;
  3176. }
  3177. .mb-3 {
  3178. margin-bottom: 12px;
  3179. }
  3180. .mb-4 {
  3181. margin-bottom: 16px;
  3182. }
  3183. .ml-2 {
  3184. margin-left: 8px;
  3185. }
  3186. .line-meta-cell {
  3187. display: flex;
  3188. align-items: center;
  3189. gap: 4px;
  3190. min-width: 0;
  3191. }
  3192. .line-meta-cell__control {
  3193. flex: 1;
  3194. min-width: 0;
  3195. }
  3196. .line-meta-cell__empty {
  3197. flex: 1;
  3198. min-width: 0;
  3199. }
  3200. .line-meta-cell__btn {
  3201. flex-shrink: 0;
  3202. padding: 4px;
  3203. }
  3204. .customer-select-row {
  3205. display: flex;
  3206. align-items: center;
  3207. gap: 4px;
  3208. width: 100%;
  3209. }
  3210. .customer-select-row__select {
  3211. flex: 1;
  3212. min-width: 0;
  3213. }
  3214. .customer-select-row__search {
  3215. flex-shrink: 0;
  3216. padding: 0 8px;
  3217. }
  3218. .customer-option-row {
  3219. display: flex;
  3220. justify-content: space-between;
  3221. align-items: center;
  3222. gap: 12px;
  3223. min-width: 0;
  3224. }
  3225. .customer-option-row__meta {
  3226. flex-shrink: 0;
  3227. font-size: 12px;
  3228. color: var(--mint-text-muted, #8492a6);
  3229. }
  3230. </style>
  3231. <style>
  3232. .contract-form-dialog.el-dialog {
  3233. width: calc(100vw - 48px) !important;
  3234. max-width: calc(100vw - 48px) !important;
  3235. margin: 0 auto !important;
  3236. }
  3237. </style>