| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523 |
- <template>
- <el-dialog
- v-model="dialogVisible"
- :title="isPageVariant ? undefined : formTitle"
- :width="isPageVariant ? '100%' : 'calc(100vw - 48px)'"
- :fullscreen="isPageVariant"
- :show-close="!isPageVariant"
- :modal="!isPageVariant"
- :append-to-body="!isPageVariant"
- align-center
- :class="[
- 'dialog-form-layout',
- 'contract-form-dialog',
- { 'contract-form-dialog--page': isPageVariant }
- ]"
- destroy-on-close
- :modal-class="isPageVariant ? undefined : 'order-module-dialog-mask'"
- :close-on-click-modal="false"
- @opened="handleOpened"
- >
- <div
- class="contract-form-body"
- v-loading="formLoading"
- element-loading-text="正在加载合同数据..."
- >
- <el-form
- ref="formRef"
- :model="form"
- :rules="rules"
- :label-position="isPageVariant ? 'top' : 'right'"
- :label-width="isPageVariant ? undefined : '120px'"
- class="crm-dialog-form"
- :class="{ 'crm-dialog-form--page': isPageVariant }"
- :disabled="formLoading"
- >
- <!-- 合同基础信息(一行 4 列) -->
- <div class="contract-basic-info">
- <el-form-item label="合同编号" prop="contractNo">
- <el-input v-model="form.contractNo" placeholder="系统自动生成,格式:SX/X-JT20250926002" maxlength="50" />
- </el-form-item>
- <el-form-item label="合同名称" prop="contractName">
- <el-input
- v-model="form.contractName"
- placeholder="可不填,自动生成:供方简称-需方简称(合同编号)"
- maxlength="100"
- show-word-limit
- />
- </el-form-item>
- <el-form-item label="合同类型" prop="contractType" required>
- <el-select v-model="form.contractType" placeholder="请选择合同类型" style="width: 100%" @change="handleContractTypeChange">
- <el-option v-for="dict in dictStore.getOptions('crm_contract_type')" :key="dict.value" :label="dict.label" :value="dict.value" />
- </el-select>
- </el-form-item>
- <el-form-item label="合同模版" prop="contractTemplate" required>
- <el-select v-model="form.contractTemplate" placeholder="请选择合同模版" style="width: 100%" @change="handleContractTemplateChange">
- <el-option v-for="dict in dictStore.getOptions('crm_contract_template')" :key="dict.value" :label="dict.label" :value="dict.value" />
- </el-select>
- </el-form-item>
- <el-form-item label="客户名称" prop="customerId" required>
- <div class="customer-select-row">
- <el-select
- v-model="form.customerId"
- class="customer-select-row__select"
- filterable
- remote
- clearable
- :remote-method="searchCustomerOptions"
- :loading="customerOptionsLoading"
- placeholder="请输入客户名称/拼音首字母搜索"
- @focus="handleCustomerSelectFocus"
- @change="handleCustomerSelectChange"
- >
- <el-option
- v-for="item in customerOptions"
- :key="item.id"
- :label="item.customerName"
- :value="item.id"
- >
- <div class="customer-option-row">
- <span>{{ item.customerName }}</span>
- <span v-if="item.customerNo" class="customer-option-row__meta">{{ item.customerNo }}</span>
- </div>
- </el-option>
- </el-select>
- <el-button text class="customer-select-row__search" @click="handleSelectCustomer">
- <el-icon><Search /></el-icon>
- </el-button>
- </div>
- </el-form-item>
- <el-form-item label="关联报价单" prop="quoteId">
- <el-input v-model="form.quoteNo" placeholder="请选择报价单(可选)" readonly clearable>
- <template #append>
- <el-button text @click="handleSelectQuote">
- <el-icon><Search /></el-icon>
- </el-button>
- </template>
- </el-input>
- </el-form-item>
- <el-form-item label="联系人" prop="contactId" required>
- <div class="customer-select-row">
- <el-select
- v-model="form.contactId"
- class="customer-select-row__select"
- filterable
- clearable
- :disabled="!form.customerId"
- :loading="contactOptionsLoading"
- :placeholder="form.customerId ? '请选择联系人' : '请先选择客户'"
- @visible-change="handleContactSelectVisible"
- @change="handleContactSelectChange"
- >
- <el-option
- v-for="item in contactOptions"
- :key="item.id"
- :label="item.contactName"
- :value="item.id"
- >
- <div class="customer-option-row">
- <span>
- {{ item.contactName }}
- <el-tag v-if="item.isPrimary === 1" type="success" size="small" style="margin-left: 6px">主要</el-tag>
- </span>
- <span v-if="item.mobile" class="customer-option-row__meta">{{ item.mobile }}</span>
- </div>
- </el-option>
- </el-select>
- <el-button
- text
- class="customer-select-row__search"
- :disabled="!form.customerId"
- @click="handleSelectContact"
- >
- <el-icon><Search /></el-icon>
- </el-button>
- </div>
- </el-form-item>
- <el-form-item label="联系电话" prop="contactsPhone">
- <el-input v-model="form.contactsPhone" placeholder="从客户/联系人带出" maxlength="20" />
- </el-form-item>
- <el-form-item label="签订日期" prop="signDate">
- <el-date-picker
- v-model="form.signDate"
- type="date"
- placeholder="选择签订日期"
- value-format="YYYY-MM-DD"
- style="width: 100%"
- @change="handleSignDateChange"
- />
- </el-form-item>
- <el-form-item label="负责人" prop="ownerUserId" required>
- <el-select
- v-model="form.ownerUserId"
- placeholder="请选择负责人"
- filterable
- clearable
- style="width: 100%"
- @change="handleOwnerChange"
- >
- <el-option
- v-for="user in ownerUserList"
- :key="user.userId"
- :label="user.nickName"
- :value="user.userId"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="合同金额(元)" prop="amount">
- <el-input-number
- v-model="form.amount"
- :precision="2"
- :min="0"
- placeholder="可选填,留空则从产品明细自动计算"
- style="width: 100%"
- class="money-field-input"
- />
- </el-form-item>
- <el-form-item label="运费(元)" prop="freight">
- <el-input-number
- v-model="form.freight"
- :precision="2"
- :min="0"
- placeholder="可选,选择报价单后可自动带入"
- style="width: 100%"
- class="money-field-input"
- />
- </el-form-item>
- <el-form-item label="桶押金(元)" prop="barrelDeposit">
- <el-input-number
- v-model="form.barrelDeposit"
- :precision="2"
- :min="0"
- placeholder="可选,选择报价单后可自动带入"
- style="width: 100%"
- class="money-field-input"
- />
- </el-form-item>
- <el-form-item label="合同期限" prop="startDate" required>
- <el-date-picker
- v-model="contractDateRange"
- type="daterange"
- range-separator="-"
- start-placeholder="开始日期"
- end-placeholder="结束日期"
- value-format="YYYY-MM-DD"
- style="width: 100%"
- @change="handleDateRangeChange"
- />
- </el-form-item>
- <el-form-item label="交易主体" prop="supplierId" required>
- <el-select v-model="form.supplierId" placeholder="请选择交易主体" clearable style="width: 100%" @change="handleSupplierChange">
- <el-option v-for="supplier in supplierList" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" />
- </el-select>
- </el-form-item>
- <el-form-item label="结算方式" prop="settlementMethod" required>
- <el-select v-model="form.settlementMethod" placeholder="请选择结算方式" clearable style="width: 100%">
- <el-option v-for="dict in dictStore.getOptions('crm_settlement_method')" :key="dict.value" :label="dict.label" :value="dict.value" />
- </el-select>
- </el-form-item>
- </div>
- <!-- 供方 / 需方信息(左右布局,默认缩略、各自独立展开) -->
- <div class="parties-section">
- <div class="parties-section-toolbar">
- <el-button text type="primary" size="small" @click="partiesExpanded = !partiesExpanded">
- <el-icon>
- <ArrowUp v-if="partiesExpanded" />
- <ArrowDown v-else />
- </el-icon>
- {{ partiesExpanded ? '收起供需方' : '展开供需方' }}
- </el-button>
- <el-button type="primary" size="small" @click="handleSwapParties">
- <el-icon><Sort /></el-icon>
- 供需方调换
- </el-button>
- </div>
- <div class="parties-row" :key="'parties-swap-' + partiesSwapEpoch">
- <div class="party-panel" :class="{ 'is-collapsed': !partiesExpanded }">
- <div class="party-panel__header">
- <div class="party-panel__title">供方信息</div>
- </div>
- <div v-if="!partiesExpanded" class="party-panel__summary">
- <span class="party-panel__summary-label">单位名称</span>
- <span class="party-panel__summary-value" :class="{ 'is-empty': !form.partyA }">
- {{ form.partyA || '未填写' }}
- </span>
- </div>
- <div v-show="partiesExpanded" class="form-grid-2">
- <el-form-item label="单位名称(章)" prop="partyA">
- <el-input v-model="form.partyA" placeholder="请输入单位名称" maxlength="100" />
- </el-form-item>
- <el-form-item label="单位地址" prop="partyAAddress">
- <el-input v-model="form.partyAAddress" placeholder="请输入单位地址" maxlength="200" />
- </el-form-item>
- <el-form-item label="法定代表人" prop="partyALegalPerson">
- <el-input v-model="form.partyALegalPerson" placeholder="请输入法定代表人" maxlength="100" />
- </el-form-item>
- <el-form-item label="代理人" prop="partyAAgent">
- <el-input v-model="form.partyAAgent" placeholder="请输入代理人(可选)" maxlength="100" />
- </el-form-item>
- <el-form-item label="电话/传真" prop="partyAPhoneFax">
- <el-input v-model="form.partyAPhoneFax" placeholder="请输入电话/传真" maxlength="100" />
- </el-form-item>
- <el-form-item label="开户银行" prop="partyABank">
- <el-input v-model="form.partyABank" placeholder="请输入开户银行" maxlength="200" />
- </el-form-item>
- <el-form-item label="账号" prop="partyAAccount">
- <el-input v-model="form.partyAAccount" placeholder="请输入账号" maxlength="100" />
- </el-form-item>
- </div>
- </div>
- <div class="party-flow-bridge" aria-hidden="true">
- <span class="party-flow-bridge__label">供货</span>
- <span class="party-flow-bridge__arrow">
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
- <path
- d="M5 12h11.5"
- stroke="currentColor"
- stroke-width="2.75"
- stroke-linecap="round"
- />
- <path
- d="M13.5 8.5 18.5 12l-5 3.5"
- stroke="currentColor"
- stroke-width="2.75"
- stroke-linecap="round"
- stroke-linejoin="round"
- />
- </svg>
- </span>
- </div>
- <div class="party-panel" :class="{ 'is-collapsed': !partiesExpanded }">
- <div class="party-panel__header">
- <div class="party-panel__title">需方信息</div>
- </div>
- <div v-if="!partiesExpanded" class="party-panel__summary">
- <span class="party-panel__summary-label">单位名称</span>
- <span class="party-panel__summary-value" :class="{ 'is-empty': !form.partyB }">
- {{ form.partyB || '未填写' }}
- </span>
- </div>
- <div v-show="partiesExpanded" class="form-grid-2">
- <el-form-item label="单位名称(章)" prop="partyB">
- <el-input v-model="form.partyB" placeholder="从客户开票信息带出" maxlength="200" />
- </el-form-item>
- <el-form-item label="单位地址" prop="partyBAddress">
- <el-input v-model="form.partyBAddress" placeholder="从客户开票信息带出" maxlength="500" />
- </el-form-item>
- <el-form-item label="法定代表人" prop="partyBLegalPerson">
- <el-input v-model="form.partyBLegalPerson" placeholder="从客户开票信息带出" maxlength="100" />
- </el-form-item>
- <el-form-item label="代理人" prop="partyBAgent">
- <el-input v-model="form.partyBAgent" placeholder="从客户开票信息带出(可选)" maxlength="100" />
- </el-form-item>
- <el-form-item label="电话/传真" prop="partyBPhoneFax">
- <el-input v-model="form.partyBPhoneFax" placeholder="从客户开票信息带出" maxlength="100" />
- </el-form-item>
- <el-form-item label="开户银行" prop="partyBBank">
- <el-input v-model="form.partyBBank" placeholder="从客户开票信息带出" maxlength="200" />
- </el-form-item>
- <el-form-item label="账号" prop="partyBAccount">
- <el-input v-model="form.partyBAccount" placeholder="从客户开票信息带出" maxlength="100" />
- </el-form-item>
- </div>
- </div>
- </div>
- </div>
- <!-- 产品明细部分 -->
- <el-divider content-position="left">产品明细</el-divider>
- <div class="panel dialog-list-panel mb-4">
- <div class="list-toolbar">
- <div class="list-toolbar-row">
- <div class="list-toolbar-selected">
- <el-button class="btn-main" type="primary" @click="handleAddProduct">
- <el-icon><Plus /></el-icon>添加产品
- </el-button>
- <el-button class="btn-ghost" @click="handleRemoveSelectedProducts" :disabled="selectedProducts.length === 0">
- <el-icon><Delete /></el-icon>删除选中
- </el-button>
- </div>
- </div>
- </div>
- <div class="list-body list-wrap">
- <ListTable table-key="crm-contract-components-contract-form-dialog" :data="form.products" class="list-table" stripe @selection-change="handleProductSelectionChange">
- <el-table-column type="selection" width="55" align="center" header-align="center" />
- <el-table-column label="序号" type="index" width="60" align="center" header-align="center" />
- <el-table-column label="产品名称" min-width="160" :show-overflow-tooltip="true">
- <template #default="scope">
- <el-input
- :model-value="scope.row.standardProductName || scope.row.productName"
- placeholder="产品名称"
- clearable
- size="small"
- readonly
- />
- </template>
- </el-table-column>
- <el-table-column label="别名" min-width="140">
- <template #default="scope">
- <div class="line-meta-cell">
- <el-select
- v-if="(scope.row.aliasOptions || []).length"
- v-model="scope.row.aliasName"
- placeholder="不选则用产品名称"
- clearable
- filterable
- size="small"
- class="line-meta-cell__control"
- >
- <el-option
- v-for="name in scope.row.aliasOptions"
- :key="name"
- :label="name"
- :value="name"
- />
- </el-select>
- <span v-else class="text-muted line-meta-cell__empty">—</span>
- <el-button
- v-if="scope.row.productId"
- link
- type="primary"
- class="line-meta-cell__btn"
- @click="openAliasBrandManage(scope.row, 'alias')"
- >
- <el-icon><Plus /></el-icon>
- </el-button>
- </div>
- </template>
- </el-table-column>
- <el-table-column label="品牌" min-width="140">
- <template #default="scope">
- <div class="line-meta-cell">
- <el-select
- v-if="(scope.row.brandOptions || []).length"
- v-model="scope.row.brandId"
- placeholder="不限品牌"
- clearable
- filterable
- size="small"
- class="line-meta-cell__control"
- @change="(val) => handleBrandIdChange(scope.row, val)"
- >
- <el-option
- v-for="b in scope.row.brandOptions"
- :key="b.id"
- :label="b.brandName"
- :value="b.id"
- />
- </el-select>
- <span v-else class="text-muted line-meta-cell__empty">—</span>
- <el-button
- v-if="scope.row.productId"
- link
- type="primary"
- class="line-meta-cell__btn"
- @click="openAliasBrandManage(scope.row, 'brand')"
- >
- <el-icon><Plus /></el-icon>
- </el-button>
- </div>
- </template>
- </el-table-column>
- <el-table-column label="型号/规格" prop="productModel" min-width="100" :show-overflow-tooltip="true">
- <template #default="scope">
- <el-input v-model="scope.row.productModel" placeholder="请输入型号/规格" clearable size="small" readonly />
- </template>
- </el-table-column>
- <el-table-column label="税率(%)" prop="taxRate" min-width="80">
- <template #default="scope">
- <el-input v-model="scope.row.taxRate" placeholder="税率" @input="handleTaxRateChange(scope.row)" clearable size="small" />
- </template>
- </el-table-column>
- <el-table-column label="单位" prop="unit" width="100" align="center">
- <template #default="scope">
- <DictSelect
- v-model="scope.row.unit"
- dict-type="crm_product_unit"
- placeholder="请选择单位"
- size="small"
- style="width: 100%"
- />
- </template>
- </el-table-column>
- <el-table-column prop="quantity" width="130">
- <template #header> <span style="color: #f56c6c">*</span> 数量</template>
- <template #default="scope">
- <div v-if="isFrameworkContract" class="framework-pricing-cell">
- <template v-if="isFrameworkQuantityCustomerDemand(scope.row)">
- <span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_QUANTITY_LABEL }}</span>
- <el-button
- link
- type="primary"
- class="framework-pricing-cell__action"
- @click="handleEnableFrameworkQuantityFixed(scope.row)"
- >
- 填数字
- </el-button>
- </template>
- <template v-else>
- <el-input
- v-model="scope.row.quantity"
- placeholder="数量"
- @input="handleQuantityChange(scope.row)"
- clearable
- size="small"
- />
- <el-button
- link
- type="primary"
- class="framework-pricing-cell__action"
- @click="handleResetFrameworkQuantity(scope.row)"
- >
- 按客户需求
- </el-button>
- </template>
- </div>
- <el-input
- v-else
- v-model="scope.row.quantity"
- placeholder="数量"
- @input="handleQuantityChange(scope.row)"
- clearable
- size="small"
- />
- </template>
- </el-table-column>
- <el-table-column prop="price" width="150">
- <template #header>
- <span v-if="isFrameworkContract">含税单价(元)</span>
- <span v-else><span style="color: #f56c6c">*</span> 含税单价(元)</span>
- </template>
- <template #default="scope">
- <div v-if="isFrameworkContract" class="framework-pricing-cell">
- <template v-if="isFrameworkPriceMarket(scope.row)">
- <span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
- <el-button
- link
- type="primary"
- class="framework-pricing-cell__action"
- @click="handleEnableFrameworkPriceFixed(scope.row)"
- >
- 填数字
- </el-button>
- </template>
- <template v-else>
- <el-input
- v-model="scope.row.price"
- placeholder="含税单价"
- @input="handlePriceChange(scope.row)"
- clearable
- size="small"
- class="money-field-input"
- />
- <el-button
- link
- type="primary"
- class="framework-pricing-cell__action"
- @click="handleResetFrameworkPrice(scope.row)"
- >
- 随行就市
- </el-button>
- </template>
- </div>
- <el-input
- v-else
- v-model="scope.row.price"
- placeholder="含税单价"
- @input="handlePriceChange(scope.row)"
- clearable
- size="small"
- class="money-field-input"
- />
- </template>
- </el-table-column>
- <el-table-column prop="subtotal" width="150" align="right">
- <template #header>
- <span v-if="isFrameworkContract">含税总价(元)</span>
- <span v-else><span style="color: #f56c6c">*</span> 含税总价(元)</span>
- </template>
- <template #default="scope">
- <div v-if="isFrameworkContract" class="framework-pricing-cell framework-pricing-cell--money">
- <template v-if="isFrameworkPriceMarket(scope.row)">
- <span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
- <el-button
- link
- type="primary"
- class="framework-pricing-cell__action"
- @click="handleEnableFrameworkPriceFixed(scope.row)"
- >
- 填数字
- </el-button>
- </template>
- <span v-else class="money-display">{{ formatFrameworkMoneyDisplay(scope.row, 'subtotal') }}</span>
- </div>
- <span v-else class="money-display">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
- </template>
- </el-table-column>
- <el-table-column label="税费(元)" prop="taxAmount" min-width="120" align="right">
- <template #default="scope">
- <div v-if="isFrameworkContract" class="framework-pricing-cell framework-pricing-cell--money">
- <template v-if="isFrameworkPriceMarket(scope.row)">
- <span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
- <el-button
- link
- type="primary"
- class="framework-pricing-cell__action"
- @click="handleEnableFrameworkPriceFixed(scope.row)"
- >
- 填数字
- </el-button>
- </template>
- <span v-else class="money-display">{{ formatFrameworkMoneyDisplay(scope.row, 'taxAmount') }}</span>
- </div>
- <span v-else class="money-display">¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="packageSpecs" min-width="220" class-name="col-package-specs">
- <template #header> <span style="color: #f56c6c">*</span> 包装规格</template>
- <template #default="scope">
- <DictSelect
- v-model="scope.row.packageSpecs"
- dict-type="crm_product_gg"
- placeholder="请选择包装规格"
- size="small"
- multiple
- class="package-specs-select"
- style="width: 100%"
- />
- </template>
- </el-table-column>
- <el-table-column prop="deliveryMethod" min-width="150">
- <template #header> <span style="color: #f56c6c">*</span> 送货方式</template>
- <template #default="scope">
- <DictSelect
- v-model="scope.row.deliveryMethod"
- dict-type="crm_shfs"
- placeholder="请选择送货方式"
- size="small"
- style="width: 100%"
- />
- </template>
- </el-table-column>
- <el-table-column prop="remark" label="备注" min-width="160">
- <template #default="scope">
- <el-input
- v-model="scope.row.remark"
- type="textarea"
- :rows="2"
- placeholder="请输入备注"
- maxlength="500"
- show-word-limit
- clearable
- size="small"
- />
- </template>
- </el-table-column>
- <el-table-column label="操作" min-width="80" align="center" header-align="center" fixed="right" class-name="col-actions">
- <template #default="scope">
- <el-button link type="danger" @click="handleRemoveProduct(scope.$index)">
- <el-icon><Delete /></el-icon>
- </el-button>
- </template>
- </el-table-column>
- </ListTable>
- </div>
- </div>
- <!-- 框架合同调价区间 -->
- <template v-if="isFrameworkContractType(form.contractType)">
- <el-divider content-position="left">框架合同调价区间</el-divider>
- <p class="text-muted mb-3" style="font-size: 13px">
- 按产品维护生效日期区间与含税单价(可选);若填写区间则须完整(日期、单价、调价函)。合同总金额不与此联动,仍为 0。
- </p>
- <div v-for="(prod, pIdx) in form.products" :key="'fw-tier-' + pIdx" class="framework-tier-block mb-4">
- <div class="fw-tier-title mb-2">{{ prod.productName || '产品' + (pIdx + 1) }}</div>
- <el-button type="primary" link class="mb-2 fw-tier-letter-add-btn" @click="addPriceTierRow(pIdx)">
- <el-icon><Plus /></el-icon>调价函
- </el-button>
- <div class="panel dialog-list-panel mb-3">
- <div class="list-body list-wrap dialog-list-body-only">
- <ListTable table-key="crm-contract-components-contract-form-dialog-2" :data="prod.priceTiers || []" class="list-table fw-price-tier-table" stripe empty-text="暂无调价区间">
- <el-table-column type="index" label="#" width="50" align="center" header-align="center" />
- <el-table-column label="开始日期" min-width="150">
- <template #default="scope">
- <el-date-picker v-model="scope.row.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
- </template>
- </el-table-column>
- <el-table-column label="结束日期" min-width="150">
- <template #default="scope">
- <el-date-picker v-model="scope.row.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
- </template>
- </el-table-column>
- <el-table-column label="含税单价(元)" width="140">
- <template #default="scope">
- <el-input v-model="scope.row.price" placeholder="含税单价" clearable size="small" class="money-field-input" />
- </template>
- </el-table-column>
- <el-table-column label="调价函" min-width="220">
- <template #default="scope">
- <AttachmentUploadField
- :model-value="tierLetterCsv(scope.row)"
- accept="image/*"
- :limit="20"
- compact
- :biz-type="CRM_UPLOAD_BIZ_TYPE.CONTRACT_PRICE_LETTER"
- @update:model-value="syncTierLetterCsv(scope.row, $event)"
- />
- </template>
- </el-table-column>
- <el-table-column label="操作" min-width="70" align="center" header-align="center" fixed="right" class-name="col-actions">
- <template #default="scope">
- <el-button link type="danger" @click="removePriceTierRow(pIdx, scope.$index)">
- <el-icon><Delete /></el-icon>
- </el-button>
- </template>
- </el-table-column>
- </ListTable>
- </div>
- </div>
- </div>
- </template>
- <el-divider />
- <div class="total-section">
- <el-row :gutter="20">
- <el-col :span="16"></el-col>
- <el-col :span="8">
- <div class="total-item">
- <span class="total-label">合计金额(元):</span>
- <span class="total-value money-display">¥{{ parseFloat(form.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</span>
- </div>
- </el-col>
- </el-row>
- </div>
- <!-- 附件上传部分 - OpenSpec 标准卡片化布局 -->
- <el-divider content-position="left">附件上传</el-divider>
- <el-form-item label="附件" prop="attachmentFiles" :required="form.contractTemplate === 'customer_template'">
- <ContractAttachmentPanel
- v-model="attachmentList"
- :biz-type="CRM_UPLOAD_BIZ_TYPE.CONTRACT_ATTACHMENT"
- />
- </el-form-item>
- </el-form>
- </div>
- <template #footer>
- <div class="dialog-footer">
- <el-button class="btn-ghost" @click="handleClose">
- <el-icon><Close /></el-icon>取消
- </el-button>
- <el-button
- class="btn-ghost"
- :loading="saveDraftLoading"
- :disabled="formLoading"
- @click="handleSaveDraft"
- >
- <el-icon><FolderChecked /></el-icon>保存草稿
- </el-button>
- <el-button
- class="btn-main"
- type="primary"
- :loading="submitLoading"
- :disabled="formLoading"
- @click="handleSubmit"
- >
- <el-icon><Promotion /></el-icon>提交审批
- </el-button>
- </div>
- </template>
- <!-- 产品选择器 -->
- <ProductSelector
- :visible="productSelectorVisible"
- :supplier-id="form.supplierId"
- @update:visible="productSelectorVisible = $event"
- @confirm="handleProductSelected"
- />
- <ProductAliasBrandDialog
- v-model="aliasBrandManageVisible"
- :product-id="aliasBrandManageProductId"
- :product-name="aliasBrandManageProductName"
- :mode="aliasBrandManageMode"
- @saved="handleAliasBrandManaged"
- />
- <!-- 客户选择器 -->
- <CustomerSelector
- :visible="customerSelectorVisible"
- @update:visible="customerSelectorVisible = $event"
- @confirm="handleCustomerSelectorConfirm"
- />
- <!-- 下拉选客户时补填简称 -->
- <el-dialog
- v-model="shortNameDialogVisible"
- title="填写客户简称"
- width="480px"
- align-center
- class="dialog-form-layout"
- modal-class="order-module-dialog-mask"
- :close-on-click-modal="false"
- append-to-body
- @closed="handleShortNameDialogClosed"
- >
- <el-form
- ref="shortNameFormRef"
- :model="shortNameForm"
- :rules="shortNameRules"
- label-position="top"
- class="crm-dialog-form"
- >
- <el-form-item label="客户名称">
- <el-input :model-value="shortNameForm.customerName" disabled />
- </el-form-item>
- <el-form-item label="客户简称" prop="shortName">
- <el-input
- v-model="shortNameForm.shortName"
- placeholder="请输入客户简称"
- maxlength="100"
- show-word-limit
- clearable
- />
- </el-form-item>
- </el-form>
- <template #footer>
- <div class="dialog-footer">
- <el-button class="btn-ghost" @click="shortNameDialogVisible = false">
- <el-icon><Close /></el-icon>取消
- </el-button>
- <el-button class="btn-main" type="primary" :loading="shortNameSaving" @click="handleSaveShortName">
- <el-icon><Check /></el-icon>确定
- </el-button>
- </div>
- </template>
- </el-dialog>
- <!-- 联系人选择器 -->
- <ContactSelector
- :visible="contactSelectorVisible"
- @update:visible="contactSelectorVisible = $event"
- :customerId="form.customerId"
- @confirm="handleContactConfirm"
- />
- <!-- 下拉无联系人时补充 -->
- <ContactFormDialog ref="contactFormRef" @success="handleContactFormSuccess" />
- <!-- 报价单选择器 -->
- <QuoteSelector
- :visible="quoteSelectorVisible"
- @update:visible="quoteSelectorVisible = $event"
- :customerId="form.customerId"
- @confirm="handleQuoteConfirm"
- />
- <!-- 用户选择器 -->
- <!-- 审批弹窗 -->
- <ApprovalDialog ref="approvalDialogRef" @success="handleApprovalSuccess" />
- </el-dialog>
- </template>
- <script setup lang="ts">
- import { ref, reactive, computed, nextTick, watch } from 'vue'
- import type { FormInstance, FormItemRule } from 'element-plus'
- import { ElMessage } from 'element-plus'
- import {
- getContract,
- addContract,
- updateContract,
- type ContractForm,
- type ContractProductForm,
- type ContractProductPriceTierForm,
- type ContractVO
- } from '@/api/crm/contract/index'
- import { isFrameworkContractType, isPurchaseLikeContractType } from '../contractType'
- import {
- FRAMEWORK_QUANTITY_LABEL,
- FRAMEWORK_PRICE_LABEL,
- applyFrameworkDefaultsToProduct,
- createFrameworkProductPricingDefaults,
- enableFrameworkPriceFixed,
- enableFrameworkQuantityFixed,
- formatFrameworkMoneyDisplay,
- isBlankNumeric,
- isFrameworkPriceMarket,
- isFrameworkQuantityCustomerDemand,
- isNegativeNumeric,
- confirmZeroPricingIfNeeded,
- normalizeFrameworkProductPricing,
- prepareFrameworkProductForSubmit,
- resetFrameworkPriceToMarket,
- resetFrameworkQuantityToCustomerDemand
- } from '../frameworkProductPricing'
- import { useDictStore } from '@/store/modules/dict'
- import { useUserStore } from '@/store/modules/user'
- import { $modal } from '@/utils/modal'
- import {
- Document,
- Delete,
- Sort,
- Plus,
- Search,
- Close,
- Check,
- FolderChecked,
- Promotion,
- Loading,
- ArrowDown,
- ArrowUp
- } from '@element-plus/icons-vue'
- import {
- buildUploadFileListFromField,
- serializeContractAttachments,
- type ContractAttachmentItem
- } from '@/utils/crm-attachment'
- import { CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
- import ContractAttachmentPanel from './ContractAttachmentPanel.vue'
- import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
- import { getOpportunity } from '@/api/crm/opportunity'
- import { getQuote } from '@/api/crm/quote'
- import ProductSelector from '@/components/business/product-selector/index.vue'
- import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
- import DictSelect from '@/components/DictSelect/index.vue'
- import CustomerSelector from '@/components/CustomerSelector.vue'
- import ContactSelector from '@/components/ContactSelector.vue'
- import ContactFormDialog from '@/views/crm/customer/components/ContactFormDialog.vue'
- import QuoteSelector from '@/components/QuoteSelector.vue'
- import { fetchContactsByCustomerId } from '@/api/crm/contact'
- import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
- import {
- applyBrandSelectChange,
- applyManagedProductMetaToLines,
- initLineBrandMetaFromProduct,
- prepareLineAliasAndBrandForSubmit,
- resolveAliasFromSavedName
- } from '@/utils/crm-product-brand'
- import { fetchProductDetail } from '@/api/crm/product'
- import ApprovalDialog from './ApprovalDialog.vue'
- import {
- applyPartySwapSnapshots,
- clearPartyFields,
- omitEmptyExceptPartyFields,
- preparePartySwap
- } from '../partyFields'
- import {
- fetchCustomerList,
- fetchCustomerDetail,
- fetchUpdateCustomer
- } from '@/api/crm/customer'
- import type { CustomerVO, ContactVO } from '@/api/crm/customer/types'
- import { getDefaultInvoiceInfoByCustomerId } from '@/api/crm/invoiceInfo'
- import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo'
- import { fetchUserOptions } from '@/api/admin'
- import { filterOwnerCandidates, findDefaultOwnerUser } from '@/utils/crm-owner-candidates'
- import {
- buildPackageSpecsPayload,
- normalizeContractProductPackageFields,
- validateContractProductPackageSpecs
- } from '@/utils/crm-package-spec'
- interface UserVO {
- userId: string | number
- nickName: string
- userName?: string
- }
- // 扩展表单类型,添加显示用字段
- type ExtendedContractForm = ContractForm & {
- customerName?: string
- opportunityName?: string
- quoteNo?: string
- contactsName?: string
- contactsPhone?: string
- ownerUserName?: string
- supplierId?: number
- settlementMethod?: string
- templateContent?: string
- }
- // 扩展产品类型,添加显示字段
- type PriceTierRow = ContractProductPriceTierForm
- type ExtendedContractProductForm = ContractProductForm & {
- productName?: string
- productModel?: string
- unit?: string
- unitPrice?: number
- subtotal?: number
- priceTiers?: PriceTierRow[]
- }
- const props = withDefaults(
- defineProps<{
- visible: boolean
- editId: string | null
- /** 复制来源合同(列表选中行,含 products) */
- copySource?: ContractVO | null
- /** dialog=桌面弹窗;page=小程序 WebView 全屏页 */
- variant?: 'dialog' | 'page'
- }>(),
- {
- copySource: null,
- variant: 'dialog'
- }
- )
- const emit = defineEmits<{
- 'update:visible': [value: boolean]
- success: []
- 'loading-change': [loading: boolean]
- }>()
- const isPageVariant = computed(() => props.variant === 'page')
- const dialogVisible = computed({
- get: () => props.visible,
- set: (val) => emit('update:visible', val)
- })
- const dictStore = useDictStore()
- const userStore = useUserStore()
- // 表单标题
- const formTitle = computed(() => {
- if (props.editId) return '编辑合同'
- if (props.copySource) return '复制合同'
- return '新增合同'
- })
- // 合同期限日期范围
- const contractDateRange = ref<[string, string]>([])
- // 表单数据
- const form = ref<ExtendedContractForm>({
- id: undefined,
- contractNo: undefined,
- contractName: '',
- contractType: 'sale',
- contractTemplate: 'shanxia',
- customerId: undefined,
- customerName: '',
- contactId: undefined,
- opportunityId: undefined,
- opportunityName: '',
- quoteId: undefined,
- quoteNo: '',
- contactsName: '',
- contactsPhone: '',
- signDate: '',
- startDate: '',
- endDate: '',
- ownerUserId: undefined,
- ownerUserName: '',
- amount: 0,
- freight: 0,
- barrelDeposit: 0,
- supplierId: undefined,
- settlementMethod: undefined,
- partyA: '',
- partyAAddress: '',
- partyAContact: '',
- partyAMobile: '',
- partyALegalPerson: '',
- partyAAgent: '',
- partyAPhoneFax: '',
- partyABank: '',
- partyAAccount: '',
- partyB: '',
- partyBAddress: '',
- partyBContact: '',
- partyBMobile: '',
- partyBLegalPerson: '',
- partyBAgent: '',
- partyBPhoneFax: '',
- partyBBank: '',
- partyBAccount: '',
- paymentTerms: '',
- deliveryTerms: '',
- contractTerms: '',
- products: [] as ExtendedContractProductForm[],
- remark: ''
- })
- // 供需方是否已调换的标志
- const isPartiesSwapped = ref(false)
- /** 调换后递增,强制重挂载供需方输入框,避免 el-input 显示与 model 不同步 */
- const partiesSwapEpoch = ref(0)
- /** 供方 / 需方信息展开状态(默认缩略,两侧共用) */
- const partiesExpanded = ref(false)
- const resetPartyExpand = () => {
- partiesExpanded.value = false
- }
- /** 校验失败时,自动展开供需方面板 */
- const expandPartiesForInvalidFields = (invalidFields?: Record<string, unknown>) => {
- if (!invalidFields) return
- const keys = Object.keys(invalidFields)
- if (keys.some((k) => k.startsWith('partyA') || k.startsWith('partyB'))) {
- partiesExpanded.value = true
- }
- }
- // 记录原始的模板内容(用于检测是否被意外删除)
- const originalTemplateContent = ref<string>('')
- // 供方信息列表
- const supplierList = ref<SupplierInfoVO[]>([])
- // 客户简称 / 开票公司名(用于按供需方面板匹配简称)
- const customerShortName = ref('')
- const customerInvoiceName = ref('')
- // 表单校验规则
- const rules = ref({
- contractType: [{ required: true, message: '合同类型不能为空', trigger: 'change' }],
- customerId: [{ required: true, message: '客户不能为空', trigger: 'change' }],
- contactId: [{ required: true, message: '联系人不能为空', trigger: 'change' }],
- signDate: [{ required: true, message: '签订日期不能为空', trigger: 'change' }],
- ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'change' }],
- supplierId: [{ required: true, message: '交易主体不能为空', trigger: 'change' }],
- settlementMethod: [{ required: true, message: '结算方式不能为空', trigger: 'change' }],
- startDate: [
- {
- validator: (_rule: any, _value: any, callback: any) => {
- if (!contractDateRange.value || contractDateRange.value.length !== 2 || !contractDateRange.value[0] || !contractDateRange.value[1]) {
- callback(new Error('合同期限不能为空'))
- } else {
- callback()
- }
- },
- trigger: ['change', 'blur']
- }
- ],
- attachmentFiles: []
- })
- // 附件列表
- const attachmentList = ref<ContractAttachmentItem[]>([])
- const isFrameworkContract = computed(() => isFrameworkContractType(form.value.contractType))
- // 加载状态
- const formLoading = ref(false)
- const saveDraftLoading = ref(false)
- const submitLoading = ref(false)
- /** 编辑打开时立即进入加载态,避免空白表单可被误改 */
- watch(
- () => props.visible,
- (visible) => {
- if (visible && (props.editId || props.copySource)) {
- formLoading.value = true
- emit('loading-change', true)
- } else if (!visible) {
- formLoading.value = false
- emit('loading-change', false)
- }
- }
- )
- watch(formLoading, (loading) => {
- if (props.visible) emit('loading-change', loading)
- })
- // 组件引用
- const formRef = ref<FormInstance>()
- const approvalDialogRef = ref()
- // 选择器状态
- const customerSelectorVisible = ref(false)
- const contactSelectorVisible = ref(false)
- const opportunitySelectorVisible = ref(false)
- const quoteSelectorVisible = ref(false)
- const userSelectorVisible = ref(false)
- const productSelectorVisible = ref(false)
- // 客户下拉(远程模糊检索)
- const customerOptions = ref<CustomerVO[]>([])
- const customerOptionsLoading = ref(false)
- /** 已确认选定的客户(用于简称弹窗取消时回滚下拉值) */
- const confirmedCustomerId = ref<string | number | undefined>(undefined)
- const shortNameDialogVisible = ref(false)
- const shortNameSaving = ref(false)
- const shortNameFormRef = ref<FormInstance>()
- const shortNameForm = ref({
- id: '' as string | number,
- customerName: '',
- shortName: ''
- })
- const shortNameRules: Record<string, FormItemRule[]> = {
- shortName: [{ required: true, message: '客户简称不能为空', trigger: 'blur' }]
- }
- const pendingCustomerForShortName = ref<CustomerVO | null>(null)
- const shortNameSavedSuccessfully = ref(false)
- // 联系人下拉
- const contactOptions = ref<ContactVO[]>([])
- const contactOptionsLoading = ref(false)
- const contactFormRef = ref<InstanceType<typeof ContactFormDialog>>()
- /** 避免 visible-change 重复弹出补充联系人弹窗 */
- const contactEmptyPrompted = ref(false)
- const hasCustomerShortName = (row?: { shortName?: string } | null) =>
- Boolean(String(row?.shortName || '').trim())
- const ensureCustomerOption = (customer?: Partial<CustomerVO> | null) => {
- if (!customer?.id) return
- const exists = customerOptions.value.some((item) => String(item.id) === String(customer.id))
- if (exists) {
- customerOptions.value = customerOptions.value.map((item) =>
- String(item.id) === String(customer.id)
- ? {
- ...item,
- customerName: customer.customerName || item.customerName,
- customerNo: customer.customerNo ?? item.customerNo,
- shortName: customer.shortName ?? item.shortName
- }
- : item
- )
- return
- }
- customerOptions.value = [
- {
- id: customer.id,
- customerName: customer.customerName || '',
- customerNo: customer.customerNo,
- shortName: customer.shortName
- } as CustomerVO,
- ...customerOptions.value
- ]
- }
- const extractCustomerList = (res: unknown): CustomerVO[] => {
- const data = (res as { data?: unknown })?.data ?? res ?? {}
- const list = (data as { list?: CustomerVO[]; rows?: CustomerVO[] }).list
- || (data as { rows?: CustomerVO[] }).rows
- || []
- return Array.isArray(list) ? list : []
- }
- const loadDefaultCustomerOptions = async () => {
- customerOptionsLoading.value = true
- try {
- const res = await fetchCustomerList({ page: 1, pageSize: 20 })
- customerOptions.value = extractCustomerList(res)
- if (form.value.customerId) {
- ensureCustomerOption({
- id: form.value.customerId,
- customerName: form.value.customerName,
- shortName: customerShortName.value
- })
- }
- } catch (error) {
- console.error('加载客户列表失败:', error)
- customerOptions.value = []
- } finally {
- customerOptionsLoading.value = false
- }
- }
- const searchCustomerOptions = async (query: string) => {
- const keyword = query?.trim()
- if (!keyword) {
- await loadDefaultCustomerOptions()
- return
- }
- customerOptionsLoading.value = true
- try {
- const res = await fetchCustomerList({
- customerName: keyword,
- page: 1,
- pageSize: 50
- })
- customerOptions.value = extractCustomerList(res)
- if (form.value.customerId) {
- ensureCustomerOption({
- id: form.value.customerId,
- customerName: form.value.customerName,
- shortName: customerShortName.value
- })
- }
- } catch (error) {
- console.error('搜索客户失败:', error)
- customerOptions.value = []
- } finally {
- customerOptionsLoading.value = false
- }
- }
- const handleCustomerSelectFocus = () => {
- if (customerOptions.value.length === 0) {
- loadDefaultCustomerOptions()
- }
- }
- const extractContactList = (res: unknown): ContactVO[] => {
- const unwrapped = (res as { data?: unknown })?.data ?? res ?? []
- if (Array.isArray(unwrapped)) return unwrapped as ContactVO[]
- const payload = (unwrapped as { list?: ContactVO[]; rows?: ContactVO[] }) || {}
- return payload.list || payload.rows || []
- }
- const ensureContactOption = (contact?: Partial<ContactVO> | null) => {
- if (!contact?.id) return
- const exists = contactOptions.value.some((item) => String(item.id) === String(contact.id))
- if (exists) {
- contactOptions.value = contactOptions.value.map((item) =>
- String(item.id) === String(contact.id)
- ? {
- ...item,
- contactName: contact.contactName || item.contactName,
- mobile: contact.mobile ?? item.mobile,
- isPrimary: contact.isPrimary ?? item.isPrimary
- }
- : item
- )
- return
- }
- contactOptions.value = [
- {
- id: contact.id,
- contactName: contact.contactName || '',
- mobile: contact.mobile,
- isPrimary: contact.isPrimary
- } as ContactVO,
- ...contactOptions.value
- ]
- }
- const loadContactOptions = async (customerId?: string | number) => {
- if (!customerId) {
- contactOptions.value = []
- return []
- }
- contactOptionsLoading.value = true
- try {
- const res = await fetchContactsByCustomerId({ customerId })
- contactOptions.value = extractContactList(res)
- if (form.value.contactId) {
- ensureContactOption({
- id: form.value.contactId,
- contactName: form.value.contactsName
- })
- }
- return contactOptions.value
- } catch (error) {
- console.error('加载联系人列表失败:', error)
- contactOptions.value = []
- return []
- } finally {
- contactOptionsLoading.value = false
- }
- }
- const openContactFormDialog = () => {
- if (!form.value.customerId) {
- $modal.msgWarning('请先选择客户')
- return
- }
- contactFormRef.value?.open(undefined, form.value.customerId)
- }
- const openShortNameDialog = (customer: CustomerVO) => {
- pendingCustomerForShortName.value = customer
- shortNameSavedSuccessfully.value = false
- shortNameForm.value = {
- id: customer.id,
- customerName: customer.customerName || '',
- shortName: customer.shortName || ''
- }
- shortNameDialogVisible.value = true
- nextTick(() => shortNameFormRef.value?.clearValidate())
- }
- const clearCustomerSelection = () => {
- form.value.customerId = undefined
- form.value.customerName = ''
- customerShortName.value = ''
- customerInvoiceName.value = ''
- confirmedCustomerId.value = undefined
- form.value.contactId = undefined
- form.value.contactsName = ''
- form.value.contactsPhone = ''
- contactOptions.value = []
- contactEmptyPrompted.value = false
- form.value.opportunityId = undefined
- form.value.opportunityName = ''
- form.value.quoteId = undefined
- form.value.quoteNo = ''
- form.value.freight = 0
- form.value.barrelDeposit = 0
- rebuildContractName()
- }
- // 用户列表(负责人下拉仅方金强 / 吴九华)
- const userList = ref<any[]>([])
- const ownerUserList = computed(() => filterOwnerCandidates(userList.value))
- // 选中的产品列表
- const selectedProducts = ref<ExtendedContractProductForm[]>([])
- // 临时接口定义
- interface OpportunityVO {
- id: number
- opportunityName: string
- customerId?: number
- customerName?: string
- }
- interface QuoteVO {
- id: number
- quoteNo: string
- quoteTitle: string
- customerId?: number
- customerName?: string
- contactId?: number
- contactName?: string
- productList?: any[]
- quoteDate?: string
- }
- /** 对话框打开后的初始化 */
- const handleOpened = async () => {
- resetPartyExpand()
- // 组件实例复用时必须先清空;否则无附件合同会残留上一份合同的 attachmentList
- resetForm()
- const isEdit = !!props.editId
- const isCopy = !!props.copySource
- if (isEdit || isCopy || isPageVariant.value) {
- formLoading.value = true
- }
- try {
- await Promise.all([loadSupplierList(), loadUserList()])
- if (props.editId) {
- await loadContractDetail(props.editId)
- } else if (props.copySource) {
- await loadContractForCopy(props.copySource)
- } else {
- await initNewContractForm()
- }
- } catch (error: any) {
- console.error('合同表单初始化失败:', error)
- $modal.msgError(error?.message || '加载合同数据失败')
- if (isEdit || isCopy) {
- dialogVisible.value = false
- }
- } finally {
- formLoading.value = false
- }
- }
- /** 新增合同表单默认值 */
- const initNewContractForm = async () => {
- resetForm()
- form.value.signDate = new Date().toISOString().split('T')[0]
- handleSignDateChange(form.value.signDate)
- applyDefaultOwnerUser()
- const defaultSupplier = supplierList.value.find((supplier) => supplier.supplierCode === 'SX')
- if (defaultSupplier) {
- form.value.supplierId = defaultSupplier.id
- await handleSupplierChange(defaultSupplier.id)
- }
- }
- /** 重置表单 */
- const resetForm = () => {
- form.value = {
- id: undefined,
- contractNo: undefined,
- contractName: '',
- contractType: 'sale',
- contractTemplate: 'shanxia',
- customerId: undefined,
- customerName: '',
- contactId: undefined,
- opportunityId: undefined,
- opportunityName: '',
- quoteId: undefined,
- quoteNo: '',
- contactsName: '',
- contactsPhone: '',
- signDate: '',
- startDate: '',
- endDate: '',
- ownerUserId: undefined,
- ownerUserName: '',
- amount: 0,
- freight: 0,
- barrelDeposit: 0,
- supplierId: undefined,
- settlementMethod: undefined,
- partyA: '',
- partyAAddress: '',
- partyAContact: '',
- partyAMobile: '',
- partyALegalPerson: '',
- partyAAgent: '',
- partyAPhoneFax: '',
- partyABank: '',
- partyAAccount: '',
- partyB: '',
- partyBAddress: '',
- partyBContact: '',
- partyBMobile: '',
- partyBLegalPerson: '',
- partyBAgent: '',
- partyBPhoneFax: '',
- partyBBank: '',
- partyBAccount: '',
- paymentTerms: '',
- deliveryTerms: '',
- contractTerms: '',
- products: [] as ExtendedContractProductForm[],
- remark: '',
- attachmentFiles: ''
- }
- isPartiesSwapped.value = false
- partiesSwapEpoch.value = 0
- resetPartyExpand()
- originalTemplateContent.value = ''
- customerShortName.value = ''
- customerInvoiceName.value = ''
- confirmedCustomerId.value = undefined
- customerOptions.value = []
- contactOptions.value = []
- contactEmptyPrompted.value = false
- pendingCustomerForShortName.value = null
- shortNameDialogVisible.value = false
- shortNameSavedSuccessfully.value = false
- contractDateRange.value = [] as any
- attachmentList.value = []
- selectedProducts.value = []
- }
- /** 将源合同产品明细映射为表单行(复制时清除主键及数量/单价/备注) */
- const mapContractProductsFromSource = (products: any[], forCopy = false) =>
- products.map((product: any) => {
- const mapped: ExtendedContractProductForm = {
- ...product,
- id: forCopy ? undefined : product.id,
- contractId: forCopy ? undefined : product.contractId,
- productModel: product.model || product.productModel || '',
- isToxic: Number(product.isToxic || 0),
- isExplosive: Number(product.isExplosive || 0),
- brandMode: product.brandMode || undefined,
- brandId: product.brandId || undefined,
- brandName: product.brandName || undefined,
- brandOptions: [],
- priceTiers: (product.priceTiers || []).map((t: any) => {
- let ids: string[] = []
- if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
- ids = t.letterOssIds.map((id: any) => String(id))
- } else if (t.letterOssId != null && t.letterOssId !== '') {
- ids = [String(t.letterOssId)]
- }
- return {
- ...t,
- id: forCopy ? undefined : t.id,
- contractId: forCopy ? undefined : t.contractId,
- contractProductId: forCopy ? undefined : t.contractProductId,
- letterOssIds: ids,
- ...(forCopy ? { price: undefined } : {})
- }
- }),
- taxRate: product.taxRate || 6,
- taxAmount: forCopy ? 0 : (product.taxAmount ?? 0),
- deliveryMethod: product.deliveryMethod || ''
- }
- normalizeContractProductPackageFields(mapped)
- if (forCopy) {
- mapped.quantity = 0
- mapped.price = 0
- mapped.unitPrice = 0
- mapped.subtotal = 0
- mapped.remark = ''
- }
- return mapped
- })
- /** 写入产品明细并刷新金额 */
- const applyContractProductsToForm = async (products: ExtendedContractProductForm[], contractType: string) => {
- form.value.products = products
- await enrichContractProductBrandMeta(form.value.products)
- const isFw = isFrameworkContractType(contractType)
- form.value.products.forEach((product: any) => {
- normalizeFrameworkProductPricing(product, isFw)
- if (!isFw || !isFrameworkPriceMarket(product)) {
- calculateProductAmount(product)
- }
- })
- calculateTotalAmount()
- }
- /** 复制合同:仅带入产品明细,客户等信息由用户重新填写 */
- const loadContractForCopy = async (source: ContractVO) => {
- await initNewContractForm()
- const contractType = source.contractType || 'sale'
- form.value.contractType = contractType
- let products = Array.isArray(source.products) ? source.products : []
- // 列表 includeProducts 不含价阶;框架合同尝试拉详情补全,失败则回退列表明细
- if (isFrameworkContractType(contractType)) {
- try {
- const res = await getContract(source.id)
- if (res.data?.products?.length) {
- products = res.data.products
- form.value.contractType = res.data.contractType || contractType
- }
- } catch (error) {
- console.warn('框架合同复制:加载详情价阶失败,使用列表产品明细', error)
- }
- }
- if (!products.length) {
- const res = await getContract(source.id)
- products = res.data?.products || []
- form.value.contractType = res.data?.contractType || contractType
- }
- if (products.length) {
- const mapped = mapContractProductsFromSource(products, true)
- await applyContractProductsToForm(mapped, form.value.contractType)
- }
- }
- /** 加载合同详情 */
- const loadContractDetail = async (id: number | string) => {
- const res = await getContract(id)
- const contractData = res.data
- Object.assign(form.value, contractData)
- originalTemplateContent.value = contractData.templateContent || ''
- if (contractData.contactName) {
- form.value.contactsName = contractData.contactName
- }
- if (contractData.contactMobile) {
- form.value.contactsPhone = contractData.contactMobile
- }
- if (contractData.ownerUserName) {
- form.value.ownerUserName = contractData.ownerUserName
- }
- if (contractData.ownerUserId) {
- form.value.ownerUserId = contractData.ownerUserId
- }
- if (contractData.opportunityId) {
- try {
- const oppRes = await getOpportunity(contractData.opportunityId)
- if (oppRes.data) {
- form.value.opportunityName = oppRes.data.opportunityName
- }
- } catch (error) {
- console.warn('加载商机信息失败:', error)
- }
- }
- if (contractData.quoteId) {
- try {
- const quoteRes = await getQuote(contractData.quoteId)
- if (quoteRes.data) {
- form.value.quoteNo = quoteRes.data.quoteNo
- }
- } catch (error) {
- console.warn('加载报价单信息失败:', error)
- }
- }
- if (contractData.products && Array.isArray(contractData.products)) {
- const products = mapContractProductsFromSource(contractData.products)
- await applyContractProductsToForm(products, contractData.contractType)
- }
- if (form.value.startDate && form.value.endDate) {
- contractDateRange.value = [form.value.startDate, form.value.endDate]
- }
- // 无条件同步:空附件也必须清空列表,避免沿用上一份合同的附件
- await handleAttachmentData()
- customerShortName.value = ''
- customerInvoiceName.value = ''
- confirmedCustomerId.value = form.value.customerId || undefined
- contactEmptyPrompted.value = false
- if (form.value.customerId) {
- try {
- const [invoiceRes, customerRes] = await Promise.all([
- getDefaultInvoiceInfoByCustomerId(form.value.customerId),
- fetchCustomerDetail({ id: String(form.value.customerId) })
- ])
- customerInvoiceName.value = invoiceRes?.data?.companyName || ''
- customerShortName.value = customerRes?.data?.shortName || ''
- if (!form.value.customerName && customerRes?.data?.customerName) {
- form.value.customerName = customerRes.data.customerName
- }
- ensureCustomerOption({
- id: form.value.customerId,
- customerName: form.value.customerName || customerRes?.data?.customerName,
- shortName: customerShortName.value,
- customerNo: customerRes?.data?.customerNo
- })
- } catch {
- customerInvoiceName.value = ''
- ensureCustomerOption({
- id: form.value.customerId,
- customerName: form.value.customerName,
- shortName: customerShortName.value
- })
- }
- await loadContactOptions(form.value.customerId)
- if (form.value.contactId) {
- ensureContactOption({
- id: form.value.contactId,
- contactName: form.value.contactsName,
- mobile: form.value.contactsPhone
- })
- }
- } else {
- customerOptions.value = []
- contactOptions.value = []
- }
- syncPartiesSwappedFlagFromPanels()
- rebuildContractName()
- }
- /** 选择客户 */
- const handleSelectCustomer = () => {
- customerSelectorVisible.value = true
- }
- /** 下拉选择客户 */
- const handleCustomerSelectChange = async (customerId?: string | number) => {
- if (!customerId) {
- clearCustomerSelection()
- return
- }
- if (
- confirmedCustomerId.value !== undefined
- && confirmedCustomerId.value !== null
- && String(customerId) === String(confirmedCustomerId.value)
- ) {
- return
- }
- const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
- if (!customer) return
- if (!hasCustomerShortName(customer)) {
- ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续')
- form.value.customerId = confirmedCustomerId.value as any
- openShortNameDialog(customer)
- return
- }
- await handleCustomerConfirm(customer)
- }
- /** 客户选择器确认(无简称时先补填) */
- const handleCustomerSelectorConfirm = async (customer: CustomerVO) => {
- let enriched: CustomerVO = { ...customer }
- if (!hasCustomerShortName(enriched)) {
- try {
- const customerRes = await fetchCustomerDetail({ id: String(customer.id) })
- enriched = {
- ...enriched,
- customerName: customerRes?.data?.customerName || enriched.customerName,
- shortName: customerRes?.data?.shortName || enriched.shortName
- }
- } catch {
- // ignore,后续走简称弹窗
- }
- }
- if (!hasCustomerShortName(enriched)) {
- ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续')
- openShortNameDialog(enriched)
- return
- }
- await handleCustomerConfirm(enriched)
- }
- /** 客户选择确认 */
- const handleCustomerConfirm = async (customer: CustomerVO) => {
- if (customer) {
- ensureCustomerOption(customer)
- form.value.customerId = customer.id
- form.value.customerName = customer.customerName
- confirmedCustomerId.value = customer.id
- const addressParts: string[] = []
- if (customer.province) addressParts.push(customer.province)
- if (customer.city) addressParts.push(customer.city)
- if (customer.district) addressParts.push(customer.district)
- if (customer.address) addressParts.push(customer.address)
- const fullAddress = addressParts.join('')
- form.value.contactId = undefined
- form.value.contactsName = ''
- contactOptions.value = []
- contactEmptyPrompted.value = false
- form.value.opportunityId = undefined
- form.value.opportunityName = ''
- form.value.quoteId = undefined
- form.value.quoteNo = ''
- form.value.freight = 0
- form.value.barrelDeposit = 0
- try {
- const [invoiceRes, customerRes] = await Promise.all([
- getDefaultInvoiceInfoByCustomerId(customer.id),
- fetchCustomerDetail({ id: String(customer.id) })
- ])
- const invoiceData = invoiceRes?.data
- // 简称以客户主数据为准(列表/选择器可能缺字段)
- customerShortName.value =
- (customerRes?.data?.shortName || customer.shortName || '').trim()
- if (customerRes?.data?.customerName) {
- form.value.customerName = customerRes.data.customerName
- }
- ensureCustomerOption({
- id: customer.id,
- customerName: form.value.customerName,
- shortName: customerShortName.value,
- customerNo: customer.customerNo
- })
- customerInvoiceName.value = invoiceData?.companyName || ''
- if (invoiceData && invoiceData.companyName) {
- if (isPartiesSwapped.value) {
- form.value.partyA = invoiceData.companyName || ''
- form.value.partyAAddress = invoiceData.companyAddress || fullAddress || ''
- form.value.partyALegalPerson = invoiceData.legalRepresentative || ''
- form.value.partyAAgent = invoiceData.agent || ''
- form.value.partyAPhoneFax = invoiceData.phoneFax || ''
- form.value.partyABank = invoiceData.bankName || ''
- form.value.partyAAccount = invoiceData.bankAccount || ''
- } else {
- form.value.partyB = invoiceData.companyName || ''
- form.value.partyBAddress = invoiceData.companyAddress || fullAddress || ''
- form.value.partyBLegalPerson = invoiceData.legalRepresentative || ''
- form.value.partyBAgent = invoiceData.agent || ''
- form.value.partyBPhoneFax = invoiceData.phoneFax || ''
- form.value.partyBBank = invoiceData.bankName || ''
- form.value.partyBAccount = invoiceData.bankAccount || ''
- }
- } else {
- if (isPartiesSwapped.value) {
- form.value.partyA = customer.customerName
- form.value.partyAAddress = fullAddress
- form.value.partyALegalPerson = ''
- form.value.partyAAgent = ''
- form.value.partyAPhoneFax = customer.telephone || ''
- form.value.partyABank = ''
- form.value.partyAAccount = ''
- } else {
- form.value.partyB = customer.customerName
- form.value.partyBAddress = fullAddress
- form.value.partyBLegalPerson = ''
- form.value.partyBAgent = ''
- form.value.partyBPhoneFax = customer.telephone || ''
- form.value.partyBBank = ''
- form.value.partyBAccount = ''
- }
- }
- } catch (error) {
- console.error('获取客户开票信息失败:', error)
- customerInvoiceName.value = ''
- customerShortName.value = (customer.shortName || '').trim()
- if (isPartiesSwapped.value) {
- form.value.partyA = customer.customerName
- form.value.partyAAddress = fullAddress
- form.value.partyALegalPerson = ''
- form.value.partyAAgent = ''
- form.value.partyAPhoneFax = customer.telephone || ''
- form.value.partyABank = ''
- form.value.partyAAccount = ''
- } else {
- form.value.partyB = customer.customerName
- form.value.partyBAddress = fullAddress
- form.value.partyBLegalPerson = ''
- form.value.partyBAgent = ''
- form.value.partyBPhoneFax = customer.telephone || ''
- form.value.partyBBank = ''
- form.value.partyBAccount = ''
- }
- }
- if (isPartiesSwapped.value) {
- form.value.partyAContact = ''
- form.value.partyAMobile = customer.mobile || customer.telephone || ''
- } else {
- form.value.partyBContact = ''
- form.value.partyBMobile = customer.mobile || customer.telephone || ''
- }
- form.value.contactsName = ''
- form.value.contactsPhone = customer.mobile || customer.telephone || ''
- // 自动加载该客户的联系人并设置默认联系人;无联系人时立即引导补充
- const contacts = await loadContactOptions(customer.id)
- if (contacts.length > 0) {
- const primaryContact = contacts.find((c) => c.isPrimary === 1)
- const contact = primaryContact || contacts[0]
- if (contact) {
- handleContactConfirm(contact)
- }
- } else {
- contactEmptyPrompted.value = true
- ElMessage.warning('该客户暂无联系人,请先补充联系人信息')
- nextTick(() => openContactFormDialog())
- }
- if (!customerShortName.value) {
- customerShortName.value = (customer.shortName || '').trim()
- }
- rebuildContractName()
- }
- }
- const handleSaveShortName = async () => {
- const valid = await shortNameFormRef.value?.validate().catch(() => false)
- if (!valid) return
- shortNameSaving.value = true
- try {
- const trimmedShortName = shortNameForm.value.shortName.trim()
- await fetchUpdateCustomer({
- id: shortNameForm.value.id,
- shortName: trimmedShortName
- })
- ElMessage.success('客户简称保存成功')
- const pending = pendingCustomerForShortName.value
- const customer: CustomerVO = {
- id: shortNameForm.value.id,
- customerName: shortNameForm.value.customerName || pending?.customerName || '',
- customerNo: pending?.customerNo,
- shortName: trimmedShortName
- } as CustomerVO
- ensureCustomerOption(customer)
- shortNameSavedSuccessfully.value = true
- shortNameDialogVisible.value = false
- await handleCustomerConfirm(customer)
- } catch (error) {
- console.error('保存客户简称失败:', error)
- ElMessage.error('保存客户简称失败')
- } finally {
- shortNameSaving.value = false
- }
- }
- const handleShortNameDialogClosed = () => {
- pendingCustomerForShortName.value = null
- if (!shortNameSavedSuccessfully.value) {
- form.value.customerId = confirmedCustomerId.value as any
- }
- shortNameSavedSuccessfully.value = false
- }
- /** 选择联系人 */
- const handleSelectContact = () => {
- if (!form.value.customerId) {
- $modal.msgWarning('请先选择客户')
- return
- }
- contactSelectorVisible.value = true
- }
- const handleContactSelectVisible = async (visible: boolean) => {
- if (!visible) {
- contactEmptyPrompted.value = false
- return
- }
- if (!form.value.customerId) return
- const list = await loadContactOptions(form.value.customerId)
- if (list.length === 0 && !contactEmptyPrompted.value) {
- contactEmptyPrompted.value = true
- ElMessage.warning('该客户暂无联系人,请先补充联系人信息')
- nextTick(() => openContactFormDialog())
- }
- }
- const handleContactSelectChange = (contactId?: string | number) => {
- if (!contactId) {
- form.value.contactId = undefined
- form.value.contactsName = ''
- form.value.contactsPhone = ''
- return
- }
- const contact = contactOptions.value.find((item) => String(item.id) === String(contactId))
- if (contact) {
- handleContactConfirm(contact)
- }
- }
- /** 联系人选择确认 */
- const handleContactConfirm = (contact: ContactVO) => {
- if (contact) {
- ensureContactOption(contact)
- form.value.contactId = contact.id
- form.value.contactsName = contact.contactName
- form.value.contactsPhone = contact.mobile || contact.telephone || ''
- if (isPartiesSwapped.value) {
- form.value.partyAContact = contact.contactName
- form.value.partyAMobile = contact.mobile || contact.telephone || ''
- } else {
- form.value.partyBContact = contact.contactName
- form.value.partyBMobile = contact.mobile || contact.telephone || ''
- }
- formRef.value?.validateField('contactId')
- }
- }
- const handleContactFormSuccess = async (payload?: { id?: string | number }) => {
- contactEmptyPrompted.value = false
- const list = await loadContactOptions(form.value.customerId)
- const createdId = payload?.id
- if (createdId) {
- const created = list.find((item) => String(item.id) === String(createdId))
- if (created) {
- handleContactConfirm(created)
- return
- }
- }
- if (list.length === 1) {
- handleContactConfirm(list[0])
- }
- }
- /** 选择商机 */
- const handleSelectOpportunity = () => {
- opportunitySelectorVisible.value = true
- }
- /** 商机选择确认 */
- const handleOpportunityConfirm = (opportunity: OpportunityVO) => {
- if (opportunity) {
- form.value.opportunityId = opportunity.id
- form.value.opportunityName = opportunity.opportunityName
- if (opportunity.customerId && !form.value.customerId) {
- form.value.customerId = opportunity.customerId
- form.value.customerName = opportunity.customerName || ''
- }
- }
- }
- /** 选择报价单 */
- const handleSelectQuote = () => {
- quoteSelectorVisible.value = true
- }
- /** 报价单选择确认 */
- const handleQuoteConfirm = (quote: QuoteVO) => {
- if (quote) {
- form.value.quoteId = quote.id
- form.value.quoteNo = quote.quoteNo
- form.value.freight = Number(quote.freight) || 0
- form.value.barrelDeposit = Number(quote.barrelDeposit) || 0
- if (quote.customerId && !form.value.customerId) {
- form.value.customerId = quote.customerId
- form.value.customerName = quote.customerName || ''
- }
- if (quote.contactId) {
- form.value.contactId = quote.contactId
- form.value.contactsName = quote.contactName || ''
- if (isPartiesSwapped.value) {
- form.value.partyAContact = quote.contactName || ''
- } else {
- form.value.partyBContact = quote.contactName || ''
- }
- }
- if (quote.productList && quote.productList.length > 0) {
- $modal
- .confirm('是否要从报价单中带入产品明细?')
- .then(async () => {
- const isFw = isFrameworkContractType(form.value.contractType)
- form.value.products = quote.productList!.map((item: any) => {
- const hasQuotePrice = Number(item.price) > 0 || Number(item.subtotal) > 0
- const hasQuoteQty = Number(item.quantity) > 0
- const fwDefaults = createFrameworkProductPricingDefaults()
- return {
- productId: item.productId,
- productName: item.productName,
- productModel: item.model,
- categoryId: item.categoryId,
- categoryName: item.categoryName || '',
- unit: item.unit || '个',
- ...(isFw
- ? {
- ...fwDefaults,
- quantityMode: hasQuoteQty ? 'fixed' : fwDefaults.quantityMode,
- quantity: hasQuoteQty ? item.quantity || 1 : 0,
- priceMode: hasQuotePrice ? 'fixed' : fwDefaults.priceMode,
- price: hasQuotePrice ? item.price || 0 : 0,
- taxAmount: hasQuotePrice ? item.taxAmount || 0 : 0,
- subtotal: hasQuotePrice
- ? item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100)
- : 0
- }
- : {
- quantity: item.quantity || 1,
- price: item.price || 0,
- unitPrice: item.price || 0,
- taxAmount: item.taxAmount || 0,
- subtotal:
- item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100)
- }),
- taxRate: item.taxRate || 13,
- discount: item.discount || 0,
- packageSpec: item.packageSpec || '',
- packageSpecs: item.packageSpecs,
- deliveryMethod: item.deliveryMethod || '',
- deliveryDate: '',
- description: item.description || '',
- remark: item.remark || '',
- sort: item.sort,
- brandMode: item.brandMode || undefined,
- brandId: item.brandId || undefined,
- brandName: item.brandName || undefined,
- brandOptions: [],
- priceTiers: isFw ? [] : undefined
- }
- })
- form.value.products.forEach((p) => normalizeContractProductPackageFields(p))
- await enrichContractProductBrandMeta(form.value.products)
- form.value.products.forEach((p) => calculateProductAmount(p))
- calculateTotalAmount()
- })
- .catch(() => {})
- }
- }
- }
- /** 加载用户列表(负责人下拉) */
- const loadUserList = async () => {
- try {
- const res = await fetchUserOptions()
- const options = (res as any)?.data ?? res ?? []
- userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
- userId: item.value ?? item.userId ?? item.id,
- nickName: item.label ?? item.nickName ?? item.userName ?? ''
- }))
- } catch (error) {
- console.error('加载用户列表失败:', error)
- userList.value = []
- }
- }
- /** 新增时默认负责人为方金强 */
- const applyDefaultOwnerUser = () => {
- const matched = findDefaultOwnerUser(ownerUserList.value)
- if (!matched) return
- form.value.ownerUserId = matched.userId as ContractForm['ownerUserId']
- form.value.ownerUserName = matched.nickName || ''
- }
- /** 负责人变化 */
- const handleOwnerChange = (userId: string | number) => {
- const user = ownerUserList.value.find((item: any) => String(item.userId) === String(userId))
- form.value.ownerUserName = user?.nickName || ''
- }
- /** 选择负责人 */
- const handleSelectUser = () => {
- userSelectorVisible.value = true
- }
- /** 用户选择确认 */
- const handleUserConfirm = (user: UserVO) => {
- if (user) {
- form.value.ownerUserId = user.userId
- form.value.ownerUserName = user.nickName
- }
- }
- /** 供方主体变化处理 */
- const handleSupplierChange = async (supplierId: number | undefined) => {
- if (!supplierId) {
- if (isPartiesSwapped.value) {
- form.value.partyB = ''
- form.value.partyBAddress = ''
- form.value.partyBLegalPerson = ''
- form.value.partyBAgent = ''
- form.value.partyBPhoneFax = ''
- form.value.partyBBank = ''
- form.value.partyBAccount = ''
- } else {
- form.value.partyA = ''
- form.value.partyAAddress = ''
- form.value.partyALegalPerson = ''
- form.value.partyAAgent = ''
- form.value.partyAPhoneFax = ''
- form.value.partyABank = ''
- form.value.partyAAccount = ''
- }
- return
- }
- const supplier = supplierList.value.find((s) => s.id === supplierId)
- if (supplier) {
- if (isPartiesSwapped.value) {
- form.value.partyB = supplier.supplierName || ''
- form.value.partyBAddress = supplier.supplierAddress || ''
- form.value.partyBLegalPerson = supplier.legalPerson || ''
- form.value.partyBAgent = supplier.agent || ''
- form.value.partyBPhoneFax = supplier.phoneFax || ''
- form.value.partyBBank = supplier.bankName || ''
- form.value.partyBAccount = supplier.bankAccount || ''
- } else {
- form.value.partyA = supplier.supplierName || ''
- form.value.partyAAddress = supplier.supplierAddress || ''
- form.value.partyALegalPerson = supplier.legalPerson || ''
- form.value.partyAAgent = supplier.agent || ''
- form.value.partyAPhoneFax = supplier.phoneFax || ''
- form.value.partyABank = supplier.bankName || ''
- form.value.partyAAccount = supplier.bankAccount || ''
- }
- updateContractNoBySupplier(supplier.supplierCode)
- rebuildContractName()
- const allowToxic = Number((supplier as any).allowToxic || 0)
- const allowExplosive = Number((supplier as any).allowExplosive || 0)
- const { kept, removedNames } = removeProductsNotAllowedByHazard(
- form.value.products || [],
- allowToxic,
- allowExplosive
- )
- form.value.products = kept
- if (removedNames.length) {
- $modal.msgWarning(`当前主体不支持以下产品,已自动移除:${removedNames.join('、')}`)
- form.value.products.forEach((p) => calculateProductAmount(p))
- calculateTotalAmount()
- }
- }
- }
- /** 更新合同编号中的供方编码 */
- const updateContractNoBySupplier = (newSupplierCode: string | undefined) => {
- if (!form.value.contractNo || !newSupplierCode) return
- const contractNoPattern = /^([A-Z]+)\/X-JT(\d{8})(\d{3})$/
- const match = form.value.contractNo.match(contractNoPattern)
- if (match) {
- const date = match[2]
- const serialNum = match[3]
- form.value.contractNo = `${newSupplierCode}/X-JT${date}${serialNum}`
- } else {
- const index = form.value.contractNo.indexOf('/X-JT')
- if (index > 0) {
- const oldSupplierCode = form.value.contractNo.substring(0, index)
- form.value.contractNo = form.value.contractNo.replace(oldSupplierCode, newSupplierCode)
- }
- }
- }
- const CONTRACT_NO_STD_RE = /^([A-Z]+)\/X-JT(\d{8})(\d{3})$/
- /** 签订日变更时:标准编号只换日期段,保留序号;并同步名称括号内编号 */
- const syncContractNoDateFromSignDate = (signDateVal: string | null) => {
- if (!signDateVal || !form.value.contractNo) return
- const m = String(form.value.contractNo).match(CONTRACT_NO_STD_RE)
- if (!m) return
- const dateStr = signDateVal.replace(/-/g, '')
- if (dateStr.length !== 8) return
- if (m[2] === dateStr) return
- const nextNo = `${m[1]}/X-JT${dateStr}${m[3]}`
- form.value.contractNo = nextNo
- rebuildContractName()
- }
- /** 面板单位全称 → 匹配交易主体/客户主数据取简称;对不上用全称 */
- const resolvePartyShortName = (partyName?: string): string => {
- const party = (partyName || '').trim()
- if (!party) return ''
- const supplier = form.value.supplierId ? supplierList.value.find((s) => s.id === form.value.supplierId) : undefined
- const supplierName = (supplier?.supplierName || '').trim()
- const supplierShort = (supplier?.shortName || supplierName || '').trim()
- if (supplierName && party === supplierName) return supplierShort || party
- if (supplierShort && party === supplierShort) return supplierShort
- const customerName = (form.value.customerName || '').trim()
- const customerShort = (customerShortName.value || '').trim()
- const invoiceName = (customerInvoiceName.value || '').trim()
- if (customerName && party === customerName) return customerShort || customerName
- if (customerShort && party === customerShort) return customerShort
- if (invoiceName && party === invoiceName) return customerShort || invoiceName
- return party
- }
- /** 重建合同名称:始终 {供方简称}-{需方简称}({合同编号}),随面板调换 */
- const rebuildContractName = () => {
- const partyAShort = resolvePartyShortName(form.value.partyA)
- const partyBShort = resolvePartyShortName(form.value.partyB)
- const nameBody = [partyAShort, partyBShort].filter(Boolean).join('-')
- if (!nameBody) return
- const contractNo = (form.value.contractNo || '').trim()
- form.value.contractName = contractNo ? `${nameBody}(${contractNo})` : nameBody
- }
- /** 根据面板与交易主体/客户推断是否已调换(编辑回显用) */
- const syncPartiesSwappedFlagFromPanels = () => {
- const partyA = (form.value.partyA || '').trim()
- const partyB = (form.value.partyB || '').trim()
- if (!partyA && !partyB) {
- isPartiesSwapped.value = false
- return
- }
- const supplier = form.value.supplierId
- ? supplierList.value.find((s) => s.id === form.value.supplierId)
- : undefined
- const supplierName = (supplier?.supplierName || '').trim()
- if (supplierName) {
- if (partyB === supplierName && partyA !== supplierName) {
- isPartiesSwapped.value = true
- return
- }
- if (partyA === supplierName) {
- isPartiesSwapped.value = false
- return
- }
- }
- const customerName = (form.value.customerName || '').trim()
- const invoiceName = (customerInvoiceName.value || '').trim()
- if ((customerName && partyA === customerName) || (invoiceName && partyA === invoiceName)) {
- isPartiesSwapped.value = true
- return
- }
- if ((customerName && partyB === customerName) || (invoiceName && partyB === invoiceName)) {
- isPartiesSwapped.value = false
- }
- }
- /** 供需方调换:先快照 → 清空两侧 → nextTick 后交叉写回;勿调 handleSupplierChange */
- const handleSwapParties = async () => {
- const { nextPartyA, nextPartyB } = preparePartySwap(form.value as Record<string, any>)
- clearPartyFields(form.value as Record<string, any>)
- partiesSwapEpoch.value += 1
- await nextTick()
- applyPartySwapSnapshots(form.value as Record<string, any>, nextPartyA, nextPartyB)
- isPartiesSwapped.value = !isPartiesSwapped.value
- rebuildContractName()
- $modal.msgSuccess('供需方信息已调换')
- }
- /** 合同类型变化处理 */
- const handleContractTypeChange = (value: string) => {
- if (isFrameworkContractType(value)) {
- form.value.products?.forEach((p) => {
- applyFrameworkDefaultsToProduct(p)
- if (!p.priceTiers) {
- p.priceTiers = []
- }
- if (!isFrameworkPriceMarket(p)) {
- calculateProductAmount(p)
- }
- })
- calculateTotalAmount()
- } else {
- form.value.products?.forEach((p) => {
- normalizeFrameworkProductPricing(p, false)
- calculateProductAmount(p)
- })
- calculateTotalAmount()
- }
- if (isPurchaseLikeContractType(value) && !isPartiesSwapped.value) {
- $modal
- .confirm('检测到您选择了采购类合同,系统将自动调换供需方信息,是否继续?')
- .then(async () => {
- await handleSwapParties()
- })
- .catch(() => {
- rebuildContractName()
- })
- } else {
- rebuildContractName()
- }
- }
- /** 合同模版变化处理 */
- const handleContractTemplateChange = (value: string) => {
- if (value === 'customer_template') {
- ElMessage({
- message: '使用客户模版时,必须上传合同附件!',
- type: 'warning',
- duration: 3000
- })
- }
- }
- /** 合同期限日期范围变化 */
- const handleDateRangeChange = (val: [string, string] | null) => {
- if (val && val.length === 2) {
- form.value.startDate = val[0]
- form.value.endDate = val[1]
- } else {
- form.value.startDate = ''
- form.value.endDate = ''
- contractDateRange.value = [] as any
- }
- nextTick(() => {
- formRef.value?.validateField('startDate')
- })
- }
- /** 签订日期变化处理 */
- const handleSignDateChange = (val: string | null) => {
- if (val) {
- const signDate = new Date(val)
- const endDate = new Date(signDate)
- endDate.setDate(endDate.getDate() + 30)
- const formatDate = (date: Date): string => {
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
- }
- const startDateStr = formatDate(signDate)
- const endDateStr = formatDate(endDate)
- contractDateRange.value = [startDateStr, endDateStr]
- form.value.startDate = startDateStr
- form.value.endDate = endDateStr
- } else {
- contractDateRange.value = [] as any
- form.value.startDate = ''
- form.value.endDate = ''
- }
- syncContractNoDateFromSignDate(val)
- nextTick(() => {
- formRef.value?.validateField('startDate')
- })
- }
- /** 添加产品 */
- const handleAddProduct = () => {
- if (!form.value.supplierId) {
- $modal.msgWarning('请先选择交易主体')
- return
- }
- productSelectorVisible.value = true
- }
- /** 产品选择确认 */
- const handleProductSelected = (products: any[]) => {
- const isFw = isFrameworkContractType(form.value.contractType)
- products.forEach((product) => {
- const brandMeta = initLineBrandMetaFromProduct(product)
- form.value.products.push({
- productId: product.id,
- productName: product.productName,
- productModel: product.model || '',
- categoryId: product.categoryId,
- categoryName: product.categoryName || '',
- unit: product.unit || '个',
- ...(isFw ? createFrameworkProductPricingDefaults() : { quantity: 1, price: product.price || 0, taxAmount: 0, subtotal: product.price || 0 }),
- taxRate: normalizeTaxRateDisplay(product.taxRate, 13),
- packageSpecs: [],
- packageSpec: '',
- deliveryMethod: '',
- description: product.description || '',
- remark: '',
- isToxic: Number(product.isToxic || 0),
- isExplosive: Number(product.isExplosive || 0),
- ...brandMeta,
- priceTiers: isFw ? [] : undefined
- })
- })
- form.value.products.forEach((p) => {
- calculateProductAmount(p)
- })
- calculateTotalAmount()
- }
- const handleBrandIdChange = (row: ExtendedContractProductForm, val: string | undefined | null) => {
- applyBrandSelectChange(row, val, row.brandOptions || [])
- }
- const aliasBrandManageVisible = ref(false)
- const aliasBrandManageProductId = ref('')
- const aliasBrandManageProductName = ref('')
- const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
- function openAliasBrandManage(row: ExtendedContractProductForm, mode: 'alias' | 'brand') {
- if (!row.productId) return
- aliasBrandManageProductId.value = String(row.productId)
- aliasBrandManageProductName.value = String(
- row.standardProductName || row.productName || ''
- )
- aliasBrandManageMode.value = mode
- aliasBrandManageVisible.value = true
- }
- function handleAliasBrandManaged(product: Record<string, any>) {
- applyManagedProductMetaToLines(
- form.value.products || [],
- aliasBrandManageProductId.value,
- product
- )
- }
- async function enrichContractProductBrandMeta(lines: ExtendedContractProductForm[]) {
- await Promise.all(
- (lines || []).map(async (line) => {
- if (!line.productId) return
- try {
- const res: any = await fetchProductDetail({ id: String(line.productId) })
- const data = res?.data ?? res
- if (!data) return
- const meta = initLineBrandMetaFromProduct(data)
- line.standardProductName = meta.standardProductName
- line.aliasOptions = meta.aliasOptions
- line.aliasName = resolveAliasFromSavedName(
- line.productName,
- meta.standardProductName,
- meta.aliasOptions
- )
- line.brandOptions = meta.brandOptions
- if (meta.brandOptions.length) {
- if (line.brandMode === 'required' && line.brandId) {
- applyBrandSelectChange(line, line.brandId, meta.brandOptions)
- } else {
- line.brandMode = 'any'
- line.brandId = undefined
- line.brandName = undefined
- }
- } else {
- line.brandMode = undefined
- line.brandId = undefined
- line.brandName = undefined
- }
- } catch (e) {
- console.warn('enrich contract brand meta failed', line.productId, e)
- }
- })
- )
- }
- /** 规范化税率展示 */
- const normalizeTaxRateDisplay = (value: any, defaultValue = 0): number => {
- if (value === null || value === undefined || value === '') {
- return defaultValue
- }
- const num = Number(value)
- if (Number.isNaN(num)) {
- return defaultValue
- }
- return Number.isInteger(num) ? num : parseFloat(num.toString())
- }
- /** 产品选择变化 */
- const handleProductSelectionChange = (selection: ExtendedContractProductForm[]) => {
- selectedProducts.value = selection
- }
- /** 删除选中的产品 */
- const handleRemoveSelectedProducts = () => {
- if (selectedProducts.value.length === 0) return
- $modal
- .confirm(`确认删除选中的 ${selectedProducts.value.length} 个产品吗?`)
- .then(() => {
- selectedProducts.value.forEach((selectedProduct) => {
- const index = form.value.products.findIndex((p) => p === selectedProduct)
- if (index > -1) {
- form.value.products.splice(index, 1)
- }
- })
- selectedProducts.value = []
- calculateTotalAmount()
- $modal.msgSuccess('删除成功')
- })
- .catch(() => {})
- }
- /** 删除产品 */
- const handleRemoveProduct = (index: number) => {
- form.value.products.splice(index, 1)
- calculateTotalAmount()
- }
- /** 单价变化 */
- const handlePriceChange = (row: ExtendedContractProductForm) => {
- calculateProductAmount(row)
- calculateTotalAmount()
- }
- /** 数量变化 */
- const handleQuantityChange = (row: ExtendedContractProductForm) => {
- calculateProductAmount(row)
- calculateTotalAmount()
- }
- /** 税率变化 */
- const handleTaxRateChange = (row: ExtendedContractProductForm) => {
- calculateProductAmount(row)
- calculateTotalAmount()
- }
- const handleEnableFrameworkQuantityFixed = (row: ExtendedContractProductForm) => {
- enableFrameworkQuantityFixed(row)
- calculateProductAmount(row)
- calculateTotalAmount()
- }
- const handleResetFrameworkQuantity = (row: ExtendedContractProductForm) => {
- resetFrameworkQuantityToCustomerDemand(row)
- calculateProductAmount(row)
- calculateTotalAmount()
- }
- const handleEnableFrameworkPriceFixed = (row: ExtendedContractProductForm) => {
- enableFrameworkPriceFixed(row)
- calculateProductAmount(row)
- calculateTotalAmount()
- }
- const handleResetFrameworkPrice = (row: ExtendedContractProductForm) => {
- resetFrameworkPriceToMarket(row)
- calculateTotalAmount()
- }
- /** 计算产品金额 */
- const calculateProductAmount = (product: ExtendedContractProductForm) => {
- if (isFrameworkContractType(form.value.contractType) && isFrameworkPriceMarket(product)) {
- product.price = 0
- product.subtotal = 0
- product.taxAmount = 0
- return
- }
- const price = parseFloat(product.price?.toString() || '0')
- const quantity = parseFloat(product.quantity?.toString() || '0')
- const taxRate = parseFloat(product.taxRate?.toString() || '0')
- const subtotal = Math.round(price * quantity * 100) / 100
- product.subtotal = subtotal
- const taxRateDecimal = taxRate / 100
- let taxAmount = 0
- if (taxRate > 0 && subtotal > 0) {
- const taxBase = subtotal / (1 + taxRateDecimal)
- taxAmount = Math.round(taxBase * taxRateDecimal * 100) / 100
- }
- product.taxAmount = taxAmount
- }
- /** 计算合计金额(含框架合同:按需/随行就市行为 0,有小计则累加) */
- const calculateTotalAmount = () => {
- let total = 0
- form.value.products.forEach((item) => {
- const quantity = item.quantity || 0
- const price = item.price || 0
- const discount = item.discount || 0
- const subtotal = quantity * price * (1 - discount / 100)
- ;(item as ExtendedContractProductForm).subtotal = subtotal
- total += subtotal
- })
- form.value.amount = total
- }
- /** 框架合同调价函:逗号分隔 URL 与 letterOssIds 互转 */
- const tierLetterCsv = (row: PriceTierRow) => (row.letterOssIds || []).join(',')
- const syncTierLetterCsv = (row: PriceTierRow, val: string) => {
- row.letterOssIds = String(val || '')
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean)
- }
- /** 组装提交/保存的产品明细 */
- const buildProductsPayload = () => {
- const list = form.value.products || []
- list.forEach((product) => prepareLineAliasAndBrandForSubmit(product))
- return list.map((product: any, idx: number) => {
- const base: any = {
- contractId: product.contractId || form.value.id,
- productId: product.productId,
- productName: product.productName,
- productNo: product.productNo,
- categoryId: product.categoryId,
- categoryName: product.categoryName,
- model: product.productModel || product.model,
- unit: product.unit,
- quantity: product.quantity,
- price: product.price,
- discount: product.discount,
- taxRate: product.taxRate,
- taxAmount: product.taxAmount,
- subtotal: product.subtotal,
- deliveryDate: product.deliveryDate,
- description: product.description,
- ...buildPackageSpecsPayload(product),
- deliveryMethod: product.deliveryMethod,
- sort: product.sort ?? idx + 1,
- remark: product.remark,
- brandMode: product.brandMode || null,
- brandId: product.brandMode === 'required' ? product.brandId || null : null,
- brandName: product.brandMode === 'required' ? product.brandName || null : null
- }
- if (product.id) {
- base.id = product.id
- }
- if (isFrameworkContractType(form.value.contractType)) {
- const fwPricing = prepareFrameworkProductForSubmit(product)
- Object.assign(base, fwPricing)
- base.priceTiers = (product.priceTiers || [])
- .filter(
- (t: any) => t.startDate && t.endDate && t.price != null && t.price !== '' && Array.isArray(t.letterOssIds) && t.letterOssIds.length > 0
- )
- .map((t: any, i: number) => ({
- id: t.id,
- startDate: t.startDate,
- endDate: t.endDate,
- price: Number(t.price),
- letterOssIds: (t.letterOssIds || []).map((id: string) => String(id).trim()).filter(Boolean),
- sort: t.sort ?? i + 1
- }))
- }
- return base
- })
- }
- const addPriceTierRow = (pIdx: number) => {
- const p = form.value.products[pIdx]
- if (!p) return
- if (!p.priceTiers) {
- p.priceTiers = []
- }
- p.priceTiers.push({ startDate: '', endDate: '', price: undefined, letterOssIds: [] })
- }
- const removePriceTierRow = (pIdx: number, tIdx: number) => {
- const p = form.value.products[pIdx]
- if (!p?.priceTiers) return
- p.priceTiers.splice(tIdx, 1)
- }
- /** 处理附件数据 */
- const handleAttachmentData = async () => {
- attachmentList.value = buildUploadFileListFromField(
- form.value.attachmentFiles
- ) as ContractAttachmentItem[]
- }
- /** 保存草稿 */
- const handleSaveDraft = async () => {
- if (!form.value.customerId) {
- $modal.msgWarning('客户名称不能为空')
- return
- }
- if (!form.value.supplierId) {
- $modal.msgWarning('交易主体不能为空')
- return
- }
- const confirmedZero = await confirmZeroPricingIfNeeded(
- form.value.products,
- isFrameworkContractType(form.value.contractType)
- )
- if (!confirmedZero) return
- rebuildContractName()
- saveDraftLoading.value = true
- try {
- // 以界面列表为准(含空列表),避免 form 残留字段把上一份合同附件写回
- const attachmentJson = serializeContractAttachments(attachmentList.value)
- const data: any = { ...form.value }
- data.status = 'draft'
- data.approvalStatus = null
- data.attachmentFiles = attachmentJson
- omitEmptyExceptPartyFields(data)
- if (data.contactsName) {
- data.contactName = data.contactsName
- }
- if (data.contactsPhone) {
- data.contactMobile = data.contactsPhone
- }
- delete data.customerName
- delete data.opportunityName
- delete data.quoteNo
- delete data.contactsName
- delete data.contactsPhone
- delete data.ownerUserName
- delete data.templateContent
- if (data.products && Array.isArray(data.products) && data.products.length > 0) {
- ;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
- for (let i = 0; i < form.value.products.length; i++) {
- const product = form.value.products[i]
- const rowNum = i + 1
- if (!product.productId || String(product.productId).trim() === '') {
- $modal.msgWarning(`第${rowNum}行请从产品库选择产品`)
- return
- }
- if (!product.productName || product.productName.trim() === '') {
- $modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`)
- return
- }
- }
- data.products = buildProductsPayload()
- } else {
- delete data.products
- }
- if (form.value.id) {
- await updateContract(data)
- $modal.msgSuccess('保存成功')
- } else {
- await addContract(data)
- $modal.msgSuccess('保存成功')
- }
- handleClose()
- emit('success')
- } finally {
- saveDraftLoading.value = false
- }
- }
- /** 提交审批 */
- const handleSubmit = async () => {
- try {
- await formRef.value?.validate()
- } catch (error) {
- expandPartiesForInvalidFields(error as Record<string, unknown>)
- ElMessage.warning({
- message: '请填写完整的必填信息',
- duration: 3000
- })
- return
- }
- if (form.value.contractTemplate === 'customer') {
- const hasAttachments = attachmentList.value.some((file) => file.status === 'success' && file.ossId)
- if (!hasAttachments) {
- ElMessage.warning({
- message: '使用客户模版时,必须上传合同附件',
- duration: 3000
- })
- return
- }
- }
- const requiresProducts = form.value.contractType === 'sale' || form.value.contractType === 'purchase' || isFrameworkContractType(form.value.contractType)
- const validateProductRowCommon = (product: any, rowNum: number): boolean => {
- if (!product.productId || String(product.productId).trim() === '') {
- $modal.msgWarning(`第${rowNum}行请从产品库选择产品`)
- return false
- }
- if (!product.productName || String(product.productName).trim() === '') {
- $modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`)
- return false
- }
- const isFwRow = isFrameworkContractType(form.value.contractType)
- if (isFwRow) {
- if (product.quantityMode === 'fixed') {
- if (isBlankNumeric(product.quantity)) {
- $modal.msgWarning(`第${rowNum}行产品的数量不能为空`)
- return false
- }
- if (isNegativeNumeric(product.quantity)) {
- $modal.msgWarning(`第${rowNum}行产品的数量不能为负数`)
- return false
- }
- }
- } else if (isBlankNumeric(product.quantity)) {
- $modal.msgWarning(`第${rowNum}行产品的数量不能为空`)
- return false
- } else if (isNegativeNumeric(product.quantity)) {
- $modal.msgWarning(`第${rowNum}行产品的数量不能为负数`)
- return false
- }
- if (!product.deliveryMethod || String(product.deliveryMethod).trim() === '') {
- $modal.msgWarning(`第${rowNum}行产品的送货方式不能为空`)
- return false
- }
- return true
- }
- if (requiresProducts) {
- if (!form.value.products || form.value.products.length === 0) {
- $modal.msgWarning('销售合同、采购合同或框架合同必须至少添加一个产品')
- return
- }
- const packageCheck = validateContractProductPackageSpecs(form.value.products)
- if (!packageCheck.ok) {
- $modal.msgWarning(packageCheck.message)
- return
- }
- ;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
- for (let i = 0; i < form.value.products.length; i++) {
- const product = form.value.products[i]
- const rowNum = i + 1
- if (!validateProductRowCommon(product, rowNum)) return
- const isFw = isFrameworkContractType(form.value.contractType)
- if (!isFw) {
- if (isBlankNumeric(product.price)) {
- $modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
- return
- }
- if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
- $modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
- return
- }
- } else {
- if (product.priceMode === 'fixed') {
- if (isBlankNumeric(product.price)) {
- $modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
- return
- }
- if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
- $modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
- return
- }
- }
- const tiers = product.priceTiers || []
- for (let j = 0; j < tiers.length; j++) {
- const t = tiers[j]
- const tRow = j + 1
- if (!t.startDate || !t.endDate) {
- $modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:请选择开始与结束日期`)
- return
- }
- if (String(t.startDate) > String(t.endDate)) {
- $modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:开始日期不能晚于结束日期`)
- return
- }
- const pVal = t.price === null || t.price === '' ? NaN : Number(t.price)
- if (Number.isNaN(pVal) || pVal <= 0) {
- $modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:含税单价必须大于0`)
- return
- }
- if (!(t.letterOssIds?.length > 0)) {
- $modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:请上传调价函`)
- return
- }
- }
- }
- }
- } else if (form.value.products && form.value.products.length > 0) {
- ;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
- for (let i = 0; i < form.value.products.length; i++) {
- const product = form.value.products[i]
- const rowNum = i + 1
- if (!validateProductRowCommon(product, rowNum)) return
- if (isBlankNumeric(product.price)) {
- $modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
- return
- }
- if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
- $modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
- return
- }
- }
- }
- const confirmedZero = await confirmZeroPricingIfNeeded(
- form.value.products,
- isFrameworkContractType(form.value.contractType)
- )
- if (!confirmedZero) return
- submitLoading.value = true
- try {
- rebuildContractName()
- // 以界面列表为准(含空列表),避免 form 残留字段把上一份合同附件写回
- const attachmentJson = serializeContractAttachments(attachmentList.value)
- const data: any = { ...form.value }
- data.attachmentFiles = attachmentJson
- omitEmptyExceptPartyFields(data)
- if (data.contactsName) {
- data.contactName = data.contactsName
- }
- if (data.contactsPhone) {
- data.contactMobile = data.contactsPhone
- }
- delete data.customerName
- delete data.opportunityName
- delete data.quoteNo
- delete data.contactsName
- delete data.contactsPhone
- delete data.ownerUserName
- delete data.templateContent
- if (data.products && Array.isArray(data.products) && data.products.length > 0) {
- data.products = buildProductsPayload()
- } else {
- delete data.products
- }
- let contractId: string | number
- if (form.value.id) {
- await updateContract(data)
- contractId = form.value.id
- $modal.msgSuccess('保存成功')
- } else {
- const res = await addContract(data)
- contractId = res.data
- if (contractId == null || contractId === '') {
- $modal.msgError('保存成功但未返回合同 ID,请从列表重新提交审批')
- emit('success')
- return
- }
- form.value.id = contractId as any
- $modal.msgSuccess('保存成功')
- }
- approvalDialogRef.value?.open(contractId, 'submit')
- } finally {
- submitLoading.value = false
- }
- }
- /** 审批成功回调 */
- const handleApprovalSuccess = () => {
- handleClose()
- emit('success')
- }
- /** 关闭对话框 */
- const handleClose = () => {
- dialogVisible.value = false
- }
- /** 加载供方信息列表 */
- const loadSupplierList = async () => {
- try {
- const res = await listAllSupplierInfo({ status: '0' })
- supplierList.value = res.data || []
- return res.data || []
- } catch (error) {
- console.error('加载供方信息列表失败:', error)
- return []
- }
- }
- </script>
- <style scoped lang="scss">
- .contract-form-dialog {
- :deep(.el-dialog__wrapper) {
- padding: 0 !important;
- top: 20px !important;
- height: calc(100vh - 40px) !important;
- }
- :deep(.el-dialog) {
- backdrop-filter: none;
- height: 100% !important;
- max-height: calc(100vh - 40px);
- display: flex;
- flex-direction: column;
- }
- &.contract-form-dialog--page {
- :deep(.el-dialog__wrapper) {
- top: 0 !important;
- height: 100% !important;
- }
- :deep(.el-dialog) {
- max-height: 100%;
- border-radius: 0;
- box-shadow: none;
- }
- :deep(.el-dialog__header) {
- display: none;
- }
- :deep(.el-dialog__body) {
- padding: 8px 12px 16px;
- }
- :deep(.el-dialog__footer) {
- padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
- border-top: 1px solid #e2e8f0;
- }
- }
- :deep(.dialog-footer) {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 16px;
- width: 100%;
- }
- :deep(.el-dialog__header),
- :deep(.el-dialog__footer) {
- flex: 0 0 auto;
- }
- :deep(.el-dialog__body) {
- padding-top: 8px;
- flex: 1 1 auto;
- min-height: 0;
- max-height: none;
- overflow-y: auto;
- overflow-x: hidden;
- }
- }
- .contract-form-body {
- position: relative;
- min-height: 280px;
- padding: 0 4px;
- }
- .crm-dialog-form--page {
- .contract-basic-info {
- grid-template-columns: 1fr;
- }
- :deep(.el-row) {
- display: flex;
- flex-direction: column;
- }
- :deep(.el-col) {
- width: 100% !important;
- max-width: 100% !important;
- }
- }
- .contract-basic-info {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px 14px;
- margin-bottom: 20px;
- :deep(.el-form-item) {
- margin-bottom: 0;
- }
- :deep(.el-input-number) {
- width: 100%;
- }
- }
- @media (max-width: 1100px) {
- .contract-basic-info {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- }
- @media (max-width: 560px) {
- .contract-basic-info {
- grid-template-columns: 1fr;
- }
- }
- .parties-section {
- margin: 8px 0 20px;
- }
- .parties-section-toolbar {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- gap: 8px;
- margin-bottom: 12px;
- }
- .parties-row {
- display: flex;
- align-items: stretch;
- gap: 0;
- min-height: 0;
- }
- .party-panel {
- flex: 1 1 0;
- min-width: 0;
- padding: 16px;
- border: 1px solid var(--border, #d9e8e2);
- border-radius: 10px;
- background: #fff;
- &.is-collapsed {
- padding: 12px 16px;
- }
- :deep(.el-form-item) {
- margin-bottom: 0;
- }
- }
- .party-panel__header {
- display: flex;
- align-items: center;
- margin-bottom: 12px;
- .party-panel.is-collapsed & {
- margin-bottom: 8px;
- }
- }
- .party-panel__title {
- margin-bottom: 0;
- padding-left: 10px;
- font-size: 14px;
- font-weight: 600;
- color: var(--section-bar-title-color, var(--primary-dark, #059669));
- line-height: 1.5;
- border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
- }
- .party-panel__summary {
- display: flex;
- align-items: baseline;
- gap: 8px;
- min-width: 0;
- padding: 2px 0 2px 10px;
- }
- .party-panel__summary-label {
- flex-shrink: 0;
- font-size: 12px;
- color: var(--mint-text-muted, #64748b);
- line-height: 1.5;
- }
- .party-panel__summary-value {
- min-width: 0;
- font-size: 14px;
- font-weight: 500;
- color: var(--mint-text, #0f172a);
- line-height: 1.5;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- &.is-empty {
- font-weight: 400;
- color: var(--mint-text-muted, #94a3b8);
- }
- }
- .party-flow-bridge {
- flex: 0 0 88px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 10px;
- padding: 0 8px;
- }
- .party-flow-bridge__label {
- font-size: 12px;
- font-weight: 600;
- color: var(--primary-dark, #168f45);
- letter-spacing: 0.06em;
- padding: 4px 10px;
- border-radius: 999px;
- background: #e6f8ed;
- border: 1px solid #a8e0b8;
- line-height: 1.2;
- }
- .party-flow-bridge__arrow {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 44px;
- height: 44px;
- border-radius: 50%;
- color: var(--primary-dark, #168f45);
- background: linear-gradient(145deg, #e6f8ed 0%, #d4f0de 100%);
- border: 1px solid #a8e0b8;
- box-shadow: 0 2px 8px rgba(28, 168, 81, 0.14);
- svg {
- width: 22px;
- height: 22px;
- display: block;
- }
- }
- @media (max-width: 1100px) {
- .parties-row {
- flex-direction: column;
- min-height: 0;
- }
- .party-flow-bridge {
- flex: 0 0 auto;
- flex-direction: row;
- gap: 12px;
- padding: 12px 0;
- }
- .party-flow-bridge__arrow {
- transform: rotate(90deg);
- }
- }
- .total-section {
- margin-top: 20px;
- padding: 15px;
- background-color: #f5f7fa;
- border-radius: 4px;
- }
- .total-item {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- margin-bottom: 10px;
- font-size: 16px;
- &:last-child {
- margin-bottom: 0;
- }
- }
- .total-label {
- font-weight: bold;
- margin-right: 10px;
- }
- .total-value {
- font-size: 18px;
- font-weight: bold;
- }
- .text-muted {
- color: #1e293b;
- }
- :deep(.money-field-input.el-input-number .el-input__inner),
- :deep(.money-field-input.el-input-number .el-input__wrapper input) {
- color: #dc2626 !important;
- -webkit-text-fill-color: #dc2626 !important;
- font-weight: 600;
- }
- /* 统一表格内输入框和下拉框高度 */
- :deep(.el-table .el-input__wrapper) {
- height: 32px !important;
- min-height: 32px !important;
- }
- :deep(.el-table .el-select__wrapper) {
- height: 32px !important;
- min-height: 32px !important;
- }
- :deep(.el-table .el-input--small .el-input__wrapper) {
- height: 32px !important;
- min-height: 32px !important;
- }
- :deep(.el-table .el-select--small .el-select__wrapper) {
- height: 32px !important;
- min-height: 32px !important;
- }
- /* 包装规格多选:随已选项增多增高行高,展示全部标签 */
- :deep(.el-table .col-package-specs .cell) {
- overflow: visible;
- white-space: normal;
- line-height: 1.4;
- }
- :deep(.el-table .package-specs-select.el-select .el-select__wrapper),
- :deep(.el-table .package-specs-select.el-select--small .el-select__wrapper) {
- height: auto !important;
- min-height: 32px !important;
- align-items: flex-start;
- padding-top: 4px;
- padding-bottom: 4px;
- }
- :deep(.el-table .package-specs-select .el-select__selection) {
- flex-wrap: wrap;
- gap: 4px;
- }
- /* 价格输入框红色文字 */
- .price-input :deep(.el-input__inner) {
- color: #e34d59;
- }
- /* 去除输入框后置按钮的边框和背景 */
- :deep(.el-input-group__append) {
- background: transparent !important;
- border: none !important;
- box-shadow: none !important;
- }
- :deep(.el-input-group__append .el-button) {
- border: none !important;
- background: transparent !important;
- box-shadow: none !important;
- padding: 0 15px !important;
- }
- :deep(.el-input-group__append .el-button:hover) {
- background: transparent !important;
- border: none !important;
- }
- :deep(.el-input-group__append .el-button:focus) {
- background: transparent !important;
- border: none !important;
- }
- /* 框架合同产品明细:默认文案 + 可切换填数字 */
- .framework-pricing-cell {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 2px;
- min-width: 0;
- }
- .framework-pricing-cell--money {
- align-items: flex-end;
- }
- .framework-pricing-cell__label {
- font-size: 13px;
- line-height: 1.4;
- white-space: nowrap;
- }
- .framework-pricing-cell__action {
- padding: 0 !important;
- height: auto !important;
- font-size: 12px !important;
- }
- /* 框架合同调价函:表格内紧凑附件上传 */
- .fw-price-tier-table :deep(.attachment-upload--compact:not(.attachment-upload--readonly)) {
- min-width: 0;
- .attachment-upload {
- width: auto;
- }
- .attachment-list {
- flex-wrap: wrap;
- align-items: flex-start;
- }
- }
- /* 框架合同:增加「调价函」行内按钮可点区域宽度 */
- .fw-tier-letter-add-btn.el-button--primary.is-link {
- min-width: 108px;
- padding-left: 12px;
- padding-right: 12px;
- justify-content: center;
- }
- .mb-3 {
- margin-bottom: 12px;
- }
- .mb-4 {
- margin-bottom: 16px;
- }
- .ml-2 {
- margin-left: 8px;
- }
- .line-meta-cell {
- display: flex;
- align-items: center;
- gap: 4px;
- min-width: 0;
- }
- .line-meta-cell__control {
- flex: 1;
- min-width: 0;
- }
- .line-meta-cell__empty {
- flex: 1;
- min-width: 0;
- }
- .line-meta-cell__btn {
- flex-shrink: 0;
- padding: 4px;
- }
- .customer-select-row {
- display: flex;
- align-items: center;
- gap: 4px;
- width: 100%;
- }
- .customer-select-row__select {
- flex: 1;
- min-width: 0;
- }
- .customer-select-row__search {
- flex-shrink: 0;
- padding: 0 8px;
- }
- .customer-option-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- min-width: 0;
- }
- .customer-option-row__meta {
- flex-shrink: 0;
- font-size: 12px;
- color: var(--mint-text-muted, #8492a6);
- }
- </style>
- <style>
- .contract-form-dialog.el-dialog {
- width: calc(100vw - 48px) !important;
- max-width: calc(100vw - 48px) !important;
- margin: 0 auto !important;
- }
- </style>
|