forecast-form-mixin.js 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700
  1. // @ts-check
  2. /* global BigInt */
  3. /**
  4. * @fileoverview 销售预测表单混入组件
  5. * @description 提供销售预测表单的数据管理、验证规则和业务逻辑的混入组件,支持新增和编辑模式
  6. * @this {ForecastFormMixinComponent & Vue}
  7. */
  8. /**
  9. * 类型定义导入
  10. * @description 导入所有必要的TypeScript类型定义,确保类型安全
  11. */
  12. /**
  13. * @typedef {import('./types').ForecastFormModel} ForecastFormModel
  14. * @description 销售预测表单数据模型类型
  15. */
  16. /**
  17. * @typedef {import('./types').ForecastFormMixinData} ForecastFormMixinData
  18. * @description 销售预测表单混入数据类型
  19. */
  20. /**
  21. * @typedef {import('./types').CustomerOption} CustomerOption
  22. * @description 客户选项类型
  23. */
  24. /**
  25. * @typedef {import('./types').ItemOption} ItemOption
  26. * @description 物料选项类型
  27. */
  28. /**
  29. * @typedef {import('./types').ApprovalStatusOption} ApprovalStatusOption
  30. * @description 审批状态选项类型
  31. */
  32. /**
  33. * @typedef {import('./types').ForecastFormRules} ForecastFormRules
  34. * @description 销售预测表单验证规则类型
  35. */
  36. /**
  37. * @typedef {import('./types').MaterialSelectData} MaterialSelectData
  38. * @description 物料选择数据类型
  39. */
  40. /**
  41. * @typedef {import('./types').CustomerSelectData} CustomerSelectData
  42. * @description 客户选择数据类型
  43. */
  44. /**
  45. * @typedef {import('./types').ForecastFormMixinComponent} ForecastFormMixinComponent
  46. * @description 销售预测表单混入组件类型
  47. */
  48. // API接口导入
  49. import { addForecast, updateForecast, getForecastDetail } from '@/api/forecast'
  50. import { addSalesForecastMain, updateSalesForecastMain, getSalesForecastSummaryByMonth } from '@/api/forecast/forecast-summary'
  51. import { getUserLinkGoods } from '@/api/order/sales-order'
  52. // 常量和枚举导入
  53. import {
  54. APPROVAL_STATUS,
  55. APPROVAL_STATUS_OPTIONS,
  56. FORECAST_FORM_RULES,
  57. DEFAULT_FORECAST_FORM,
  58. getApprovalStatusLabel,
  59. getApprovalStatusType,
  60. canEdit
  61. } from '@/constants/forecast'
  62. // 远程搜索API
  63. import { getCustomerList, getItemList, getCustomerInfo } from '@/api/common'
  64. // 表单配置导入
  65. import { getFormOption } from './form-option'
  66. import { safeBigInt } from '@/util/util'
  67. /**
  68. * 销售预测表单事件常量
  69. * @readonly
  70. */
  71. export const FORECAST_FORM_EVENTS = {
  72. /** 表单提交成功事件 */
  73. SUBMIT_SUCCESS: 'submit-success',
  74. /** 表单取消事件 */
  75. CANCEL: 'cancel',
  76. /** 表单加载完成事件 */
  77. LOADED: 'loaded',
  78. /** 客户选择变更事件 */
  79. CUSTOMER_CHANGE: 'customer-change',
  80. /** 物料选择变更事件 */
  81. ITEM_CHANGE: 'item-change',
  82. /** 表单重置事件 */
  83. RESET: 'reset',
  84. /** 表单提交事件 */
  85. SUBMIT: 'submit',
  86. /** 表单提交失败事件 */
  87. SUBMIT_ERROR: 'submit-error',
  88. /** 更新可见性事件 */
  89. UPDATE_VISIBLE: 'update:visible',
  90. /** 保存禁用状态变更(用于父级按钮禁用控制) */
  91. SAVE_DISABLED_CHANGE: 'save-disabled-change'
  92. }
  93. /**
  94. * 销售预测表单混入
  95. * @description 提供销售预测表单的数据管理、验证规则和业务逻辑
  96. * @mixin
  97. */
  98. export default {
  99. /**
  100. * 组件名称
  101. */
  102. name: 'ForecastFormMixin',
  103. /**
  104. * 组件属性定义
  105. * @description 定义组件接收的外部属性及其类型约束
  106. */
  107. props: {
  108. /**
  109. * 表单可见性控制
  110. * @description 控制表单的显示和隐藏
  111. */
  112. visible: {
  113. type: Boolean,
  114. default: false
  115. },
  116. /**
  117. * 编辑模式标识
  118. * @description 标识当前表单是否处于编辑模式
  119. */
  120. isEdit: {
  121. type: Boolean,
  122. default: false
  123. },
  124. /**
  125. * 初始表单数据
  126. * @description 用于表单初始化的数据对象
  127. */
  128. initialFormData: {
  129. type: Object,
  130. default: null
  131. },
  132. /**
  133. * 表单标题
  134. * @description 自定义表单标题,如果不提供则根据编辑模式自动生成
  135. */
  136. title: {
  137. type: String,
  138. default: ''
  139. },
  140. /**
  141. * 编辑时的表单数据
  142. */
  143. editData: {
  144. type: Object,
  145. default: () => ({})
  146. }
  147. },
  148. /**
  149. * 组件响应式数据
  150. * @description 定义组件的响应式数据状态
  151. * @this {ForecastFormMixinComponent & Vue}
  152. * @returns {ForecastFormMixinData} 组件数据对象
  153. */
  154. data() {
  155. return {
  156. /**
  157. * 销售预测表单数据模型
  158. * @description 存储销售预测表单的所有字段数据
  159. * @type {ForecastFormModel}
  160. */
  161. formData: {
  162. id: null,
  163. forecastCode: '',
  164. year: new Date().getFullYear().toString(),
  165. month: new Date().getMonth() + 1,
  166. customerId: null,
  167. customerCode: '',
  168. customerName: '',
  169. brandId: null,
  170. brandCode: '',
  171. brandName: '',
  172. itemId: null,
  173. itemCode: '',
  174. itemName: '',
  175. specs: '',
  176. itemSpecs: '',
  177. forecastQuantity: null,
  178. currentInventory: null,
  179. approvedName: '',
  180. approvedTime: null,
  181. approvalRemark: '',
  182. createTime: null,
  183. updateTime: null
  184. },
  185. /** 保存操作加载状态 */
  186. saveLoading: false,
  187. /** 表单加载状态 */
  188. formLoading: false,
  189. /** 客户选项列表
  190. * @type {Array<CustomerOption>}
  191. */
  192. customerOptions: [],
  193. /** 客户选项加载状态 */
  194. customerLoading: false,
  195. /** 物料选项列表
  196. * @type {Array<ItemOption>}
  197. */
  198. itemOptions: [],
  199. /** 物料选项加载状态 */
  200. itemLoading: false,
  201. /** 审批状态选项列表
  202. * @type {Array<ApprovalStatusOption>}
  203. */
  204. approvalStatusOptions: APPROVAL_STATUS_OPTIONS,
  205. /** 表单验证规则
  206. * @type {ForecastFormRules}
  207. */
  208. formRules: {
  209. ...FORECAST_FORM_RULES,
  210. year: [
  211. { required: true, message: '请选择年份', trigger: 'blur' }
  212. ]
  213. },
  214. /** 表单配置
  215. * @type {import('./types').FormOption}
  216. */
  217. formOption: {
  218. column: []
  219. },
  220. /** 品牌选项列表
  221. * @type {Array<SelectOption<number>>}
  222. */
  223. brandOptions: [],
  224. /** 物料表格数据(来自用户关联商品 pjpfStockDescList),带预测数量字段 */
  225. /** @type {Array<import('@/api/types/order').PjpfStockDesc & { forecastQuantity: number, brandCode?: string }>} */
  226. stockTableData: [],
  227. /** 表格加载状态 */
  228. tableLoading: false,
  229. /** 品牌描述列表(用于品牌信息匹配) */
  230. /** @type {Array<import('@/api/types/order').PjpfBrandDesc>} */
  231. brandDescList: [],
  232. /**
  233. * 用户关联库存物料列表(不直接展示在表格中)
  234. * @type {Array<import('@/api/types/order').PjpfStockDesc>}
  235. */
  236. stockDescList: [],
  237. /**
  238. * 物料选择下拉选项(通过 cname 搜索)
  239. * @type {Array<SelectOption<string>>}
  240. */
  241. stockSelectOptions: [],
  242. /**
  243. * 当前选择待导入的物料ID
  244. * @type {string | null}
  245. */
  246. selectedStockId: null,
  247. // 选择状态:存储已选中的行唯一键(跨分页)
  248. /** @type {Array<string|number>} */
  249. selectedRowKeys: [],
  250. // 程序化同步选择的守卫标记,避免回调环
  251. /** @type {boolean} */
  252. selectionSyncing: false,
  253. /** 当前库存 */
  254. currentInventory: null,
  255. // 分页状态
  256. /** 当前页(从1开始) */
  257. currentPage: 1,
  258. /** 每页条数(默认10) */
  259. pageSize: 10
  260. }
  261. },
  262. /**
  263. * 计算属性
  264. * @description 组件的响应式计算属性
  265. */
  266. computed: {
  267. /**
  268. * 表单标题
  269. * @description 根据编辑模式动态显示表单标题
  270. * @this {ForecastFormMixinComponent & Vue}
  271. * @returns {string} 表单标题文本
  272. */
  273. formTitle() {
  274. if (this.title) {
  275. return this.title
  276. }
  277. return this.isEdit ? '编辑销售预测' : '新增销售预测'
  278. },
  279. /**
  280. * 物料总数(用于分页 total)
  281. * @returns {number}
  282. */
  283. total() {
  284. return Array.isArray(this.stockTableData) ? this.stockTableData.length : 0
  285. },
  286. /**
  287. * 当前页数据(前端分页)
  288. * @returns {Array<import('@/api/types/order').PjpfStockDesc & { forecastQuantity: number, brandCode?: string, storeInventory?: string }>}
  289. */
  290. pagedStockTableData() {
  291. const list = Array.isArray(this.stockTableData) ? this.stockTableData : []
  292. const size = Number(this.pageSize) > 0 ? Number(this.pageSize) : 10
  293. const page = Number(this.currentPage) > 0 ? Number(this.currentPage) : 1
  294. const start = (page - 1) * size
  295. const end = start + size
  296. return list.slice(start, end)
  297. },
  298. // 是否有选中项(用于禁用批量删除按钮)
  299. /** @returns {boolean} */
  300. hasSelection() {
  301. return Array.isArray(this.selectedRowKeys) && this.selectedRowKeys.length > 0
  302. }
  303. },
  304. /**
  305. * 侦听器
  306. * @description 监听属性变化并执行相应操作
  307. */
  308. watch: {
  309. /**
  310. * 监听表单可见性变化
  311. * @this {ForecastFormMixinComponent & Vue}
  312. */
  313. visible: {
  314. /**
  315. * @this {ForecastFormMixinComponent & Vue}
  316. * @param {boolean} val - 新的可见性值
  317. */
  318. handler(/** @type {boolean} */ val) {
  319. if (val) {
  320. this.$nextTick(() => {
  321. // 表单显示时,初始化表单数据
  322. if (this.initialFormData) {
  323. this.formData = this.cleanAndFormatFormData(this.initialFormData)
  324. } else {
  325. // 使用 initFormData,确保新增模式默认填入“下个月”而不是当前月
  326. this.initFormData()
  327. }
  328. // 如果是编辑模式且有ID,则加载详情数据
  329. if (this.isEdit && this.formData.id) {
  330. this.loadForecastDetail(this.formData.id)
  331. }
  332. // 如果不是编辑模式,则生成预测编码
  333. if (!this.isEdit && !this.formData.forecastCode) {
  334. // this.generateForecastCode()
  335. }
  336. // 新增模式下,自动获取并填充客户信息
  337. if (!this.isEdit) {
  338. this.loadCurrentCustomerInfo()
  339. }
  340. // 新增模式下进行年月预测存在性检查(默认年月)
  341. if (!this.isEdit) {
  342. this.checkForecastByMonthAndEmit && this.checkForecastByMonthAndEmit()
  343. }
  344. })
  345. } else {
  346. // 弹窗关闭:编辑态下清空选择,防止跨会话污染
  347. if (this.isEdit) {
  348. this.selectedRowKeys = []
  349. this.selectedStockId = null
  350. this.$nextTick(() => {
  351. this.syncTableSelection && this.syncTableSelection()
  352. })
  353. }
  354. }
  355. },
  356. immediate: true
  357. },
  358. /**
  359. * 监听初始表单数据变化
  360. * @param {ForecastFormModel} val - 新的初始表单数据
  361. * @this {ForecastFormMixinComponent & Vue}
  362. */
  363. initialFormData(/** @type {ForecastFormModel} */ val) {
  364. if (val) {
  365. this.formData = this.cleanAndFormatFormData(val)
  366. }
  367. },
  368. /**
  369. * 监听编辑数据变化
  370. * @this {ForecastFormMixinComponent & Vue}
  371. */
  372. editData: {
  373. /**
  374. * @this {ForecastFormMixinComponent & Vue}
  375. * @param {ForecastFormModel} newData
  376. */
  377. handler(/** @type {ForecastFormModel} */ newData) {
  378. if (newData && this.isEdit) {
  379. this.formData = {
  380. ...newData,
  381. year: newData.year ? newData.year.toString() : ''
  382. }
  383. // 回显子项明细到物料表格:将 pcBladeSalesForecastSummaryList -> stockTableData
  384. if (Array.isArray(newData.pcBladeSalesForecastSummaryList)) {
  385. try {
  386. this.stockTableData = newData.pcBladeSalesForecastSummaryList.map(item => ({
  387. // 尽量保持与 PjpfStockDesc 结构一致,便于表格渲染
  388. id: item.id ? safeBigInt(item.id) : undefined,
  389. goodsId: item.itemId ? safeBigInt(item.itemId) : undefined,
  390. code: item.itemCode || '',
  391. cname: item.itemName || '',
  392. brandId: item.brandId ? safeBigInt(item.brandId) : undefined,
  393. brandCode: item.brandCode || '',
  394. brandName: item.brandName || '',
  395. typeNo: item.specs || '',
  396. productDescription: item.pattern || '',
  397. brandItem: item.pattern || '',
  398. // 回显数据可能无库存,先不默认写入 '0',留给后续合并方法填充
  399. storeInventory: undefined,
  400. // 预测数量用于编辑
  401. forecastQuantity: Number(item.forecastQuantity || 0)
  402. }))
  403. // 合并接口库存数据以支持回显
  404. this.mergeEchoStoreInventory && this.mergeEchoStoreInventory().catch(() => {})
  405. } catch (e) {
  406. console.warn('映射回显明细失败:', e)
  407. }
  408. }
  409. }
  410. },
  411. immediate: true,
  412. deep: true
  413. },
  414. /**
  415. * 监听编辑模式变化
  416. * @this {ForecastFormMixinComponent & Vue}
  417. */
  418. isEdit: {
  419. /**
  420. * @this {ForecastFormMixinComponent & Vue}
  421. * @param {boolean} newVal
  422. */
  423. handler(/** @type {boolean} */ newVal) {
  424. this.initFormOption()
  425. if (!newVal) {
  426. this.initFormData()
  427. }
  428. // 切换为编辑态时,通知父级恢复保存按钮可点击
  429. if (newVal && this.$emit) {
  430. this.$emit(FORECAST_FORM_EVENTS.SAVE_DISABLED_CHANGE, false)
  431. }
  432. },
  433. immediate: true
  434. },
  435. // 新增:监听年份与月份变更以触发按月校验(仅新增模式)
  436. 'formData.year': {
  437. handler() {
  438. if (this.visible && !this.isEdit) {
  439. this.checkForecastByMonthAndEmit && this.checkForecastByMonthAndEmit()
  440. }
  441. // 年份变更重置分页到第一页
  442. this.currentPage = 1
  443. }
  444. },
  445. 'formData.month': {
  446. handler() {
  447. if (this.visible && !this.isEdit) {
  448. this.checkForecastByMonthAndEmit && this.checkForecastByMonthAndEmit()
  449. }
  450. // 月份变更重置分页到第一页
  451. this.currentPage = 1
  452. }
  453. },
  454. /**
  455. * 监听预测ID变化
  456. * @param {string|number} val - 新的预测ID
  457. * @this {ForecastFormMixinComponent & Vue}
  458. */
  459. forecastId: {
  460. /**
  461. * @this {ForecastFormMixinComponent & Vue}
  462. * @param {string|number} val
  463. */
  464. handler(/** @type {string|number} */ val) {
  465. if (val && this.isEdit && this.visible) {
  466. this.loadForecastDetail(val)
  467. }
  468. },
  469. immediate: true
  470. }
  471. },
  472. /**
  473. * 组件创建时
  474. * @this {ForecastFormMixinComponent & Vue}
  475. */
  476. created() {
  477. this.initFormOption()
  478. this.initFormData()
  479. },
  480. /**
  481. * 组件方法
  482. * @description 组件的业务逻辑方法集合
  483. */
  484. methods: {
  485. /**
  486. * 创建初始表单数据
  487. * @description 创建销售预测表单的初始数据结构
  488. * @returns {ForecastFormModel} 初始化的表单数据对象
  489. * @this {ForecastFormMixinComponent & Vue}
  490. * @private
  491. */
  492. createInitialFormData() {
  493. /** @type {ForecastFormModel} */
  494. const initial = {
  495. id: null,
  496. forecastCode: '',
  497. year: new Date().getFullYear().toString(),
  498. month: new Date().getMonth() + 1,
  499. customerId: null,
  500. customerCode: '',
  501. customerName: '',
  502. brandId: null,
  503. brandCode: '',
  504. brandName: '',
  505. itemId: null,
  506. itemCode: '',
  507. itemName: '',
  508. specs: '',
  509. itemSpecs: '',
  510. forecastQuantity: null,
  511. currentInventory: null,
  512. approvedName: '',
  513. approvedTime: null,
  514. approvalRemark: '',
  515. createTime: null,
  516. updateTime: null
  517. }
  518. return initial
  519. },
  520. /**
  521. * 清理和格式化表单数据
  522. * @description 对表单数据进行清理和格式化处理
  523. * @param {Record<string, any>} data - 原始表单数据
  524. * @returns {ForecastFormModel} 清理和格式化后的表单数据
  525. * @this {ForecastFormMixinComponent & Vue}
  526. * @private
  527. */
  528. cleanAndFormatFormData(/** @type {Record<string, any>} */ data) {
  529. // 获取下个月的年份和月份作为默认值
  530. const now = new Date()
  531. const currentYear = now.getFullYear()
  532. const currentMonth = now.getMonth() + 1
  533. let defaultYear, defaultMonth
  534. if (currentMonth === 12) {
  535. // 当前是12月,下个月是明年1月
  536. defaultYear = currentYear + 1
  537. defaultMonth = 1
  538. } else {
  539. // 其他月份,直接 +1
  540. defaultYear = currentYear
  541. defaultMonth = currentMonth + 1
  542. }
  543. return {
  544. id: data.id || null,
  545. forecastCode: String(data.forecastCode || ''),
  546. year: data.year ? data.year.toString() : defaultYear.toString(),
  547. month: Number(data.month) || defaultMonth,
  548. customerId: data.customerId ? data.customerId.toString() : null,
  549. customerCode: String(data.customerCode || ''),
  550. customerName: String(data.customerName || ''),
  551. brandId: Number(data.brandId) || null,
  552. brandCode: String(data.brandCode || ''),
  553. brandName: String(data.brandName || ''),
  554. itemId: data.itemId ? data.itemId.toString() : null,
  555. itemCode: String(data.itemCode || ''),
  556. itemName: String(data.itemName || ''),
  557. specs: String(data.specs || ''),
  558. itemSpecs: String(data.itemSpecs || data.specs || ''),
  559. forecastQuantity: data.forecastQuantity !== undefined && data.forecastQuantity !== null && data.forecastQuantity !== '' ? Number(data.forecastQuantity) : null,
  560. currentInventory: Number(data.currentInventory) || null,
  561. approvalStatus: Number(data.approvalStatus) || APPROVAL_STATUS.PENDING,
  562. approvedName: String(data.approvedName || ''),
  563. approvedTime: data.approvedTime || null,
  564. approvalRemark: String(data.approvalRemark || ''),
  565. createTime: data.createTime || null,
  566. updateTime: data.updateTime || null
  567. }
  568. },
  569. /**
  570. * 加载销售预测详情
  571. * @description 根据ID加载销售预测详情数据
  572. * @param {string|number} id - 销售预测ID
  573. * @returns {Promise<void>}
  574. * @this {ForecastFormMixinComponent & Vue}
  575. * @private
  576. */
  577. async loadForecastDetail(/** @type {string|number} */ id) {
  578. if (!id) return
  579. try {
  580. this.formLoading = true
  581. const res = await getForecastDetail(id)
  582. if (res.data && res.data.success && res.data.data) {
  583. const detailData = res.data.data
  584. this.formData = this.cleanAndFormatFormData(detailData)
  585. // 加载客户选项数据,确保客户下拉框能正确显示
  586. if (this.formData.customerId) {
  587. await this.loadCustomerOption(this.formData.customerId, this.formData.customerName)
  588. }
  589. // 加载物料选项数据,确保物料下拉框能正确显示
  590. if (this.formData.itemId) {
  591. await this.loadItemOption(this.formData.itemId, this.formData.itemName, this.formData.itemCode, this.formData.specs)
  592. }
  593. // 映射明细到表格:pcBladeSalesForecastSummaryList -> stockTableData
  594. if (Array.isArray(detailData.pcBladeSalesForecastSummaryList)) {
  595. try {
  596. this.stockTableData = detailData.pcBladeSalesForecastSummaryList.map(item => ({
  597. id: item.id != null ? item.id : undefined,
  598. goodsId: item.itemId != null ? item.itemId : undefined,
  599. code: item.itemCode || '',
  600. cname: item.itemName || '',
  601. brandId: item.brandId != null ? item.brandId : undefined,
  602. brandCode: item.brandCode || '',
  603. brandName: item.brandName || '',
  604. typeNo: item.specs || '',
  605. productDescription: item.pattern || '',
  606. brandItem: item.pattern || '',
  607. // 回显数据可能无库存,先不默认写入 '0',留给后续合并方法填充
  608. storeInventory: (item.storeInventory !== undefined && item.storeInventory !== null && item.storeInventory !== '') ? String(item.storeInventory) : undefined,
  609. forecastQuantity: Number(item.forecastQuantity || 0)
  610. }))
  611. // 合并接口库存数据以支持回显
  612. try {
  613. if (this.mergeEchoStoreInventory) {
  614. await this.mergeEchoStoreInventory()
  615. }
  616. } catch (e) {
  617. console.warn('合并库存回显失败:', e)
  618. }
  619. // 合并完成后,规范化分页并回显选择(首屏强制回显)
  620. this.normalizePageAfterMutations()
  621. } catch (e) {
  622. console.warn('映射详情明细失败:', e)
  623. }
  624. }
  625. }
  626. } catch (error) {
  627. console.error('加载销售预测详情失败:', error)
  628. } finally {
  629. this.formLoading = false
  630. }
  631. },
  632. /**
  633. * 加载单个客户选项
  634. * @description 为编辑模式加载特定客户的选项数据
  635. * @param {string|number} customerId - 客户ID
  636. * @param {string} customerName - 客户名称
  637. * @param {string} [customerCode] - 客户编码(可选)
  638. * @returns {Promise<void>}
  639. * @this {ForecastFormMixinComponent & Vue}
  640. */
  641. async loadCustomerOption(/** @type {string|number} */ customerId, /** @type {string} */ customerName, /** @type {string} */ customerCode) {
  642. if (!customerId) return
  643. try {
  644. // customer-select组件会自动处理回显,我们只需要确保formData中有正确的值
  645. // 组件的watch会监听value变化并调用loadCustomerById方法
  646. } catch (error) {
  647. console.error('加载客户选项失败:', error)
  648. }
  649. },
  650. /**
  651. * 远程搜索客户
  652. * @description 根据关键字远程搜索客户数据
  653. * @param {string} query - 搜索关键字
  654. * @returns {Promise<void>}
  655. * @this {ForecastFormMixinComponent & Vue}
  656. */
  657. async remoteSearchCustomer(/** @type {string} */ query) {
  658. if (query === '') {
  659. this.customerOptions = []
  660. return
  661. }
  662. try {
  663. this.customerLoading = true
  664. const response = await getCustomerList(1, 20, {
  665. customerName: query
  666. })
  667. if (response.data && response.data.success && response.data.data) {
  668. const { records } = response.data.data
  669. this.customerOptions = records.map(item => ({
  670. value: item.Customer_ID,
  671. label: item.Customer_NAME,
  672. customerCode: item.Customer_CODE
  673. }))
  674. }
  675. } catch (error) {
  676. console.error('搜索客户失败:', error)
  677. } finally {
  678. this.customerLoading = false
  679. }
  680. },
  681. /**
  682. * 加载当前登录客户信息并填充表单
  683. * @this {ForecastFormMixinComponent & Vue}
  684. * @returns {Promise<void>}
  685. */
  686. async loadCurrentCustomerInfo() {
  687. try {
  688. const response = await getCustomerInfo()
  689. const ok = response && response.data && response.data.success
  690. const data = ok ? response.data.data : null
  691. if (ok && data) {
  692. // 根据接口common.d.ts中的CustomerInfoData结构进行赋值
  693. this.formData.customerId = data.Customer_ID ? Number(data.Customer_ID) : null
  694. this.formData.customerCode = data.Customer_CODE || ''
  695. this.formData.customerName = data.Customer_NAME || ''
  696. }
  697. } catch (e) {
  698. console.error('获取客户信息失败:', e)
  699. } finally {
  700. // 新增模式下,无论客户信息是否获取成功,都应确保物料明细加载一次。
  701. // 使用表格是否为空作为幂等保护,避免重复加载。
  702. if (!this.isEdit && Array.isArray(this.stockTableData) && this.stockTableData.length === 0) {
  703. try {
  704. await this.loadUserLinkGoods()
  705. } catch (err) {
  706. // loadUserLinkGoods 内部已做错误提示,这里静默即可
  707. }
  708. }
  709. }
  710. },
  711. /**
  712. * 加载单个物料选项(用于编辑时显示)
  713. * @param {string|number} itemId - 物料ID
  714. * @param {string} itemName - 物料名称
  715. * @param {string} itemCode - 物料编码
  716. * @param {string} specs - 物料规格
  717. * @returns {void}
  718. * @this {ForecastFormMixinComponent & Vue}
  719. */
  720. loadItemOption(/** @type {string|number} */ itemId, /** @type {string} */ itemName, /** @type {string} */ itemCode, /** @type {string} */ specs) {
  721. if (itemId && itemName && itemCode) {
  722. const option = {
  723. label: `${itemName} (${itemCode})`,
  724. value: itemId,
  725. itemName,
  726. itemCode,
  727. specs: specs || ''
  728. }
  729. // 检查是否已存在,避免重复添加
  730. const exists = this.itemOptions.some(opt => opt.value === itemId)
  731. if (!exists) {
  732. this.itemOptions.push(option)
  733. }
  734. }
  735. },
  736. /**
  737. * 远程搜索物料
  738. * @description 根据关键字远程搜索物料数据
  739. * @param {string} query - 搜索关键字
  740. * @returns {Promise<void>}
  741. * @this {ForecastFormMixinComponent & Vue}
  742. */
  743. async remoteSearchItem(/** @type {string} */ query) {
  744. if (query === '') {
  745. this.itemOptions = []
  746. return
  747. }
  748. try {
  749. this.itemLoading = true
  750. const res = await getItemList(1, 10, {
  751. itemName: query
  752. })
  753. if (res.data && res.data.success && res.data.data) {
  754. const { records } = res.data.data
  755. this.itemOptions = records.map(item => ({
  756. value: item.id,
  757. label: `${item.Item_Name} (${item.Item_Code})`,
  758. itemName: item.Item_Name,
  759. itemCode: item.Item_Code,
  760. specs: item.Item_PECS || '',
  761. id: item.id
  762. }))
  763. }
  764. } catch (error) {
  765. console.error('搜索物料失败:', error)
  766. } finally {
  767. this.itemLoading = false
  768. }
  769. },
  770. /**
  771. * 客户选择变化处理
  772. * @description 处理客户选择变化,更新表单中的客户相关字段
  773. * @param {string|number} customerId - 客户ID
  774. * @returns {void}
  775. * @this {ForecastFormMixinComponent & Vue}
  776. */
  777. handleCustomerChange(/** @type {string|number} */ customerId) {
  778. const customer = this.customerOptions.find(item => item.value === customerId)
  779. if (customer) {
  780. this.formData.customerId = typeof customer.value === 'string' ? parseInt(customer.value) || null : customer.value
  781. this.formData.customerCode = customer.customerCode
  782. this.formData.customerName = customer.label
  783. // 触发客户变更事件
  784. this.$emit(FORECAST_FORM_EVENTS.CUSTOMER_CHANGE, customer)
  785. }
  786. },
  787. /**
  788. * 物料选择变化处理
  789. * @description 处理物料选择变化,更新表单中的物料相关字段
  790. * @param {string|number} itemId - 物料ID
  791. * @returns {void}
  792. * @this {ForecastFormMixinComponent & Vue}
  793. */
  794. handleItemChange(/** @type {string|number} */ itemId) {
  795. const item = this.itemOptions.find(option => option.value === itemId)
  796. if (item) {
  797. this.formData.itemId = typeof item.value === 'string' ? parseInt(item.value) || null : item.value
  798. this.formData.itemCode = item.itemCode
  799. this.formData.itemName = item.itemName
  800. this.formData.specs = item.specs
  801. // 触发物料变更事件
  802. this.$emit(FORECAST_FORM_EVENTS.ITEM_CHANGE, item)
  803. }
  804. },
  805. /**
  806. * 初始化表单配置
  807. * @description 根据编辑模式初始化表单配置选项
  808. * @returns {void}
  809. * @this {ForecastFormMixinComponent & Vue}
  810. */
  811. initFormOption() {
  812. this.formOption = getFormOption(this.isEdit)
  813. },
  814. /**
  815. * 初始化表单数据
  816. * @description 根据编辑模式初始化表单数据,新增模式自动填入下个月
  817. * @returns {void}
  818. * @this {ForecastFormMixinComponent & Vue}
  819. */
  820. initFormData() {
  821. if (this.isEdit && this.editData) {
  822. // 编辑模式:使用传入的数据,确保year字段为字符串格式
  823. this.formData = {
  824. ...this.editData,
  825. year: this.editData.year ? this.editData.year.toString() : ''
  826. }
  827. // 若编辑入参未包含预测编码,则根据id加载详情以保证回显
  828. try {
  829. const id = (this.editData && (this.editData.id)) || (this.formData && (this.formData.id))
  830. if (!this.formData.forecastCode && id) {
  831. this.loadForecastDetail(id)
  832. }
  833. } catch (e) {
  834. // 非关键性异常,忽略
  835. }
  836. } else {
  837. // 新增模式:使用默认数据,自动填入下个月
  838. const now = new Date()
  839. const currentYear = now.getFullYear()
  840. const currentMonth = now.getMonth() + 1
  841. let nextYear, nextMonth
  842. if (currentMonth === 12) {
  843. // 当前是12月,下个月是明年1月
  844. nextYear = currentYear + 1
  845. nextMonth = 1
  846. } else {
  847. // 其他月份,直接 +1
  848. nextYear = currentYear
  849. nextMonth = currentMonth + 1
  850. }
  851. this.formData = {
  852. id: null,
  853. forecastCode: '',
  854. year: nextYear.toString(),
  855. month: nextMonth,
  856. customerId: null,
  857. customerCode: '',
  858. customerName: '',
  859. brandId: null,
  860. brandCode: '',
  861. brandName: '',
  862. itemId: null,
  863. itemCode: '',
  864. itemName: '',
  865. specs: '',
  866. itemSpecs: '',
  867. forecastQuantity: null,
  868. currentInventory: null,
  869. approvalStatus: APPROVAL_STATUS.PENDING,
  870. approvedName: '',
  871. approvedTime: null,
  872. approvalRemark: '',
  873. createTime: null,
  874. updateTime: null
  875. }
  876. // 生成预测编码
  877. // this.generateForecastCode()
  878. }
  879. },
  880. /**
  881. * 收集当前可见表单项的必填与数值规则错误信息(用于控制台打印)
  882. * @this {ForecastFormMixinComponent & Vue}
  883. * @returns {string[]} 错误消息列表
  884. */
  885. collectValidationErrors() {
  886. try {
  887. const errors = []
  888. const option = this.formOption || {}
  889. const groups = Array.isArray(option.group) ? option.group : []
  890. const isEmpty = (v) => v === undefined || v === null || v === ''
  891. groups.forEach(group => {
  892. const columns = Array.isArray(group.column) ? group.column : []
  893. columns.forEach(field => {
  894. if (!field || !field.prop) return
  895. // 仅校验可见字段
  896. if (field.display === false) return
  897. const rules = Array.isArray(field.rules) ? field.rules : []
  898. const val = this.formData ? this.formData[field.prop] : undefined
  899. // 必填校验
  900. const requiredRule = rules.find(r => r && r.required)
  901. if (requiredRule && isEmpty(val)) {
  902. const label = field.label || field.prop
  903. const msg = requiredRule.message || `${label}为必填项`
  904. errors.push(`${label}: ${msg}`)
  905. }
  906. // 数值最小值校验
  907. const numberRule = rules.find(r => r && r.type === 'number' && (r.min !== undefined))
  908. if (numberRule && !isEmpty(val)) {
  909. const num = Number(val)
  910. if (!Number.isFinite(num) || num < numberRule.min) {
  911. const label = field.label || field.prop
  912. const msg = numberRule.message || `${label}必须不小于${numberRule.min}`
  913. errors.push(`${label}: ${msg}`)
  914. }
  915. }
  916. })
  917. })
  918. return errors
  919. } catch (e) {
  920. return []
  921. }
  922. },
  923. /**
  924. * 表单提交事件处理(Avue表单 @submit 入口)
  925. * @description 响应 avue-form 的提交事件,统一走 submitForm 逻辑
  926. * @returns {void}
  927. * @this {ForecastFormMixinComponent & Vue}
  928. */
  929. handleSubmit(form, done, loading) {
  930. try {
  931. // 先结束 Avue 内置的按钮loading,避免未调用 done 导致一直loading
  932. if (typeof done === 'function') done()
  933. console.log(this.formData)
  934. // 采用旧实现风格:通过 this.$refs.forecastForm.validate 回调进行校验
  935. if (this.$refs && this.$refs.forecastForm && typeof this.$refs.forecastForm.validate === 'function') {
  936. this.$refs.forecastForm.validate((valid) => {
  937. if (!valid) {
  938. // 编辑态下,收集并打印具体未通过原因
  939. if (this.isEdit && typeof console !== 'undefined') {
  940. const errors = this.collectValidationErrors ? this.collectValidationErrors() : []
  941. if (errors && errors.length) {
  942. console.group && console.group('表单校验未通过')
  943. errors.forEach(msg => console.error(msg))
  944. console.groupEnd && console.groupEnd()
  945. }
  946. }
  947. // 校验失败时,如存在 loading 回调(部分版本提供),尝试恢复按钮状态
  948. if (typeof loading === 'function') loading()
  949. // 通知父组件校验失败,便于父侧重置保存按钮loading
  950. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_ERROR, { message: '表单校验未通过' })
  951. return
  952. }
  953. // 校验通过后执行提交
  954. this.submitForm()
  955. .catch((e) => {
  956. console.error('提交异常:', e)
  957. // 将错误交由父组件统一处理,避免重复toast
  958. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_ERROR, e)
  959. })
  960. })
  961. } else {
  962. // 无法获取到 validate 时,直接尝试提交
  963. this.submitForm()
  964. .catch((e) => {
  965. console.error('提交异常:', e)
  966. // 将错误交由父组件统一处理,避免重复toast
  967. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_ERROR, e)
  968. })
  969. }
  970. } catch (e) {
  971. console.error('提交异常:', e)
  972. // 将错误交由父组件统一处理,避免重复toast
  973. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_ERROR, e)
  974. }
  975. },
  976. /**
  977. * 提交表单数据(main-add:提交销售预测主表及子项明细)
  978. * @returns {Promise<void>}
  979. * @this {ForecastFormMixinComponent & Vue}
  980. */
  981. async submitForm() {
  982. try {
  983. // 基础校验(客户必选)
  984. if (!this.formData.customerId) {
  985. this.$message && this.$message.warning('请选择客户')
  986. // 通知父组件失败,重置保存按钮loading
  987. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_ERROR, { message: '未选择客户' })
  988. return
  989. }
  990. // 转换年份与月份
  991. const year = typeof this.formData.year === 'string' ? parseInt(this.formData.year, 10) : this.formData.year
  992. const month = Number(this.formData.month)
  993. // 安全的ID转换:优先使用 BigInt 校验范围,再决定以 number 还是 string 传输
  994. /** @param {unknown} val @returns {string|number|''} */
  995. const toIdOutput = (val) => {
  996. if (val == null || val === '') return ''
  997. try {
  998. const bi = BigInt(String(val))
  999. const absBi = bi >= 0n ? bi : -bi
  1000. const maxSafe = BigInt(Number.MAX_SAFE_INTEGER)
  1001. if (absBi <= maxSafe) {
  1002. return Number(bi)
  1003. }
  1004. return String(bi)
  1005. } catch (e) {
  1006. return String(val)
  1007. }
  1008. }
  1009. // 安全的数值转换(用于数量等非ID字段):若不可安全表示整数,仍以字符串传输
  1010. /** @param {unknown} val @returns {number|string} */
  1011. const toSafeNumberOrString = (val) => {
  1012. if (val == null || val === '') return 0
  1013. if (typeof val === 'number') {
  1014. return Number.isFinite(val) ? val : String(val)
  1015. }
  1016. const parsed = Number(val)
  1017. return Number.isFinite(parsed) ? parsed : String(val)
  1018. }
  1019. // 组装子项明细,仅保留预测数量>0的行
  1020. const items = this.stockTableData
  1021. .filter(row => Number(row.forecastQuantity) > 0)
  1022. .map(row => {
  1023. const matchedBrand = this.brandDescList.find(b => b.cname === row.brandName)
  1024. const rawBrandId = row.brandId != null && row.brandId !== '' ? row.brandId : (matchedBrand ? matchedBrand.id : '')
  1025. const rawItemId = row.goodsId
  1026. const brandId = toIdOutput(rawBrandId)
  1027. const itemId = toIdOutput(rawItemId)
  1028. const base = {
  1029. brandId,
  1030. brandCode: row.brandCode || '',
  1031. brandName: row.brandName || (matchedBrand ? matchedBrand.cname : ''),
  1032. itemId,
  1033. itemCode: row.code || '',
  1034. itemName: row.cname || '',
  1035. specs: row.typeNo || '',
  1036. pattern: row.productDescription || row.brandItem || '',
  1037. forecastQuantity: toSafeNumberOrString(row.forecastQuantity),
  1038. approvalStatus: Number(this.formData.approvalStatus ?? 0)
  1039. }
  1040. // 编辑模式下,如果明细有 id,带上给后端做区分
  1041. if (this.isEdit && (row.id != null && row.id !== '')) {
  1042. return { id: toIdOutput(row.id), ...base }
  1043. }
  1044. return base
  1045. })
  1046. // 新增模式下需要至少一条有效明细;编辑模式下仅提交主表四个字段,不校验明细条数
  1047. if (!this.isEdit && !items.length) {
  1048. // this.$message && this.$message.warning('请至少填写一条有效的预测数量')
  1049. // 通知父组件失败,便于父侧重置保存按钮loading
  1050. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_ERROR, { message: '未填写有效的预测明细' })
  1051. return
  1052. }
  1053. // 组装载荷
  1054. const payloadBase = {
  1055. year: year || new Date().getFullYear(),
  1056. month: month || (new Date().getMonth() + 1),
  1057. approvalStatus: Number(this.formData.approvalStatus ?? 0),
  1058. pcBladeSalesForecastSummaryList: items
  1059. }
  1060. let res
  1061. if (this.isEdit && this.formData.id) {
  1062. // 更新:需要主表 id
  1063. res = await updateSalesForecastMain({ id: toIdOutput(this.formData.id), ...payloadBase })
  1064. } else {
  1065. // 新增
  1066. res = await addSalesForecastMain(payloadBase)
  1067. }
  1068. if (res && res.data && res.data.success) {
  1069. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT, res.data)
  1070. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_SUCCESS, res.data)
  1071. } else {
  1072. const msg = (res && res.data && (res.data.msg || res.data.message)) || '提交失败'
  1073. if (typeof this.setYearMonthDisabled === 'function') {
  1074. this.setYearMonthDisabled(false)
  1075. } else if (this.$refs) {
  1076. this.$nextTick(() => {
  1077. try {
  1078. if (this.$refs.yearPicker) this.$refs.yearPicker.disabled = false
  1079. if (this.$refs.monthSelect) this.$refs.monthSelect.disabled = false
  1080. } catch (e) { /* 忽略 */ }
  1081. })
  1082. }
  1083. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_ERROR, { message: msg, response: res })
  1084. }
  1085. } catch (error) {
  1086. console.error('提交表单失败:', error)
  1087. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SUBMIT_ERROR, error)
  1088. }
  1089. },
  1090. /**
  1091. * 客户选择事件处理
  1092. * @description 处理CustomerSelect组件的客户选择事件
  1093. * @param {CustomerSelectData} customerData - 客户选择数据
  1094. * @returns {void}
  1095. * @this {ForecastFormMixinComponent & Vue}
  1096. */
  1097. handleCustomerSelected(/** @type {import('./types').CustomerSelectData} */ customerData) {
  1098. if (customerData && customerData.customerId) {
  1099. this.formData.customerId = Number(customerData.customerId)
  1100. this.formData.customerCode = customerData.customerCode
  1101. this.formData.customerName = customerData.customerName
  1102. // 选中客户后加载该用户关联的品牌与库存物料(仅新增模式自动加载,编辑模式不覆盖回显数据)
  1103. if (!this.isEdit) {
  1104. this.loadUserLinkGoods()
  1105. }
  1106. } else {
  1107. this.formData.customerId = null
  1108. this.formData.customerCode = ''
  1109. this.formData.customerName = ''
  1110. // 清空表格
  1111. this.stockTableData = []
  1112. }
  1113. },
  1114. /**
  1115. * 加载用户关联商品(品牌与库存)
  1116. * @returns {Promise<void>}
  1117. * @this {ForecastFormMixinComponent & Vue}
  1118. */
  1119. async loadUserLinkGoods() {
  1120. try {
  1121. this.tableLoading = true
  1122. // 初始化容器
  1123. this.stockTableData = []
  1124. this.brandDescList = []
  1125. this.stockDescList = []
  1126. this.stockSelectOptions = []
  1127. this.selectedStockId = null
  1128. // 重置选择状态
  1129. this.selectedRowKeys = []
  1130. const res = await getUserLinkGoods()
  1131. const payload = res && res.data && res.data.data ? res.data.data : null
  1132. const brandList = (payload && payload.pjpfBrandDescList) || []
  1133. const stockList = (payload && payload.pjpfStockDescList) || []
  1134. this.brandDescList = brandList
  1135. // 存储库存列表供选择用,不直接展示到表格
  1136. this.stockDescList = stockList
  1137. // 默认显示全部物料至下方表格,预测数量默认 1,用户可手动删除不需要的物料
  1138. this.stockTableData = stockList.map(item => ({ ...item, forecastQuantity: 1 }))
  1139. // 根据表格中已有的物料,过滤下拉选项
  1140. this.updateStockSelectOptions()
  1141. // 规范化分页并回显选择(新增模式首次加载)
  1142. this.normalizePageAfterMutations()
  1143. } catch (e) {
  1144. console.error('加载用户关联商品失败:', e)
  1145. this.$message.error(e.message || '加载用户关联商品失败')
  1146. } finally {
  1147. this.tableLoading = false
  1148. }
  1149. },
  1150. /**
  1151. * 导入所选物料到下方表格
  1152. * @description 仅在点击"导入物料"按钮后,将选择的物料行添加到表格,默认预测数量为 0
  1153. * @returns {void}
  1154. * @this {ForecastFormMixinComponent & Vue}
  1155. */
  1156. handleImportSelectedStock() {
  1157. // 未选择则提示
  1158. if (!this.selectedStockId) {
  1159. this.$message.warning('请先在上方选择要导入的物料')
  1160. return
  1161. }
  1162. // 查找明细
  1163. const stock = this.stockDescList.find(s => String(s.id) === this.selectedStockId)
  1164. if (!stock) {
  1165. this.$message.error('未找到所选物料数据,请重新选择')
  1166. return
  1167. }
  1168. // 防止重复导入 - 使用多个字段进行更全面的重复检查
  1169. const exists = this.stockTableData.some(row => {
  1170. // 优先使用 id 进行匹配
  1171. if (row.id !== undefined && row.id !== null && stock.id !== undefined && stock.id !== null && String(row.id) === String(stock.id)) {
  1172. return true
  1173. }
  1174. // 使用 goodsId 进行匹配
  1175. if (row.goodsId !== undefined && row.goodsId !== null && stock.goodsId !== undefined && stock.goodsId !== null && String(row.goodsId) === String(stock.goodsId)) {
  1176. return true
  1177. }
  1178. // 使用 code 进行匹配
  1179. if (row.code && stock.code && String(row.code) === String(stock.code)) {
  1180. return true
  1181. }
  1182. return false
  1183. })
  1184. if (exists) {
  1185. this.$message.warning('该物料已在列表中')
  1186. this.selectedStockId = null
  1187. return
  1188. }
  1189. // 添加到表格,默认预测数量为 1
  1190. this.stockTableData.push({ ...stock, forecastQuantity: 1 })
  1191. // 清空已选
  1192. this.selectedStockId = null
  1193. // 导入后更新下拉选项(过滤掉已在表格中的物料)
  1194. this.updateStockSelectOptions()
  1195. // 导入后保持当前页不变;规范化分页以应对边界,并同步选择回显
  1196. this.normalizePageAfterMutations()
  1197. },
  1198. /**
  1199. * 删除物料行(分页适配)
  1200. * @param {import('./types').ForecastFormMixinData['stockTableData'][number]} row
  1201. * @param {number} index - 当前页内索引
  1202. * @returns {Promise<void>}
  1203. */
  1204. async handleDelete(row, index) {
  1205. try {
  1206. // 先通过唯一键定位(优先 id,其次 goodsId)
  1207. const keyId = row && (row.id != null ? row.id : row.goodsId)
  1208. let removeIndex = -1
  1209. if (keyId != null) {
  1210. removeIndex = this.stockTableData.findIndex(r => (r.id != null ? r.id : r.goodsId) === keyId)
  1211. }
  1212. // 如果无唯一键或未找到,则按分页换算全局索引
  1213. if (removeIndex < 0 && typeof index === 'number') {
  1214. const globalIndex = (Math.max(1, Number(this.currentPage)) - 1) * Math.max(1, Number(this.pageSize)) + index
  1215. if (globalIndex >= 0 && globalIndex < this.stockTableData.length) {
  1216. removeIndex = globalIndex
  1217. }
  1218. }
  1219. if (removeIndex < 0) {
  1220. this.$message && this.$message.warning('未定位到要删除的记录')
  1221. return
  1222. }
  1223. await this.$confirm('确认删除该物料吗?删除后可重新通过上方选择器导入。', '提示', {
  1224. type: 'warning',
  1225. confirmButtonText: '删除',
  1226. cancelButtonText: '取消'
  1227. })
  1228. // 记录待删除行的唯一键,用于同步选择状态
  1229. const removedKey = this.getRowUniqueKey(row)
  1230. this.$delete(this.stockTableData, removeIndex)
  1231. // 同步移除选择状态中的该行
  1232. if (removedKey != null) {
  1233. this.selectedRowKeys = (Array.isArray(this.selectedRowKeys) ? this.selectedRowKeys : []).filter(k => k !== removedKey)
  1234. }
  1235. // 删除后校正页码:若当前页无数据则回退到上一页
  1236. this.normalizePageAfterMutations()
  1237. // 删除后更新下拉选项(被删除的物料重新回到可选择项)
  1238. this.updateStockSelectOptions()
  1239. // [A] removed toast: deletion success message suppressed by requirement
  1240. } catch (e) {
  1241. // 用户取消不提示为错误,其他情况做日志记录
  1242. if (e && e !== 'cancel') {
  1243. console.error('删除失败:', e)
  1244. this.$message && this.$message.error('删除失败,请稍后重试')
  1245. }
  1246. }
  1247. },
  1248. /**
  1249. * 表格选择变更(el-table @selection-change)
  1250. * @param {Array<import('./types').ForecastFormMixinData['stockTableData'][number]>} selection
  1251. * @returns {void}
  1252. */
  1253. handleSelectionChange(selection) {
  1254. // 程序化同步时不触发合并逻辑,避免回调环
  1255. if (this.selectionSyncing) return
  1256. // 基于当前页数据与新选择集,维护跨页 selection 的“并集 - 当前页未选差集”
  1257. const currentPageRows = Array.isArray(this.pagedStockTableData) ? this.pagedStockTableData : []
  1258. const currentPageKeys = new Set(
  1259. currentPageRows
  1260. .map(r => this.getRowUniqueKey(r))
  1261. .filter(k => k !== undefined && k !== null && k !== '')
  1262. )
  1263. const nextSelectedOnPage = new Set(
  1264. Array.isArray(selection)
  1265. ? selection
  1266. .map(r => this.getRowUniqueKey(r))
  1267. .filter(k => k !== undefined && k !== null && k !== '')
  1268. : []
  1269. )
  1270. const prev = Array.isArray(this.selectedRowKeys) ? this.selectedRowKeys : []
  1271. const union = new Set(prev)
  1272. // 1) 移除当前页中被取消勾选的键
  1273. currentPageKeys.forEach(k => {
  1274. if (!nextSelectedOnPage.has(k)) {
  1275. union.delete(k)
  1276. }
  1277. })
  1278. // 2) 加入当前页新勾选的键
  1279. nextSelectedOnPage.forEach(k => {
  1280. union.add(k)
  1281. })
  1282. this.selectedRowKeys = Array.from(union)
  1283. },
  1284. /**
  1285. * 行唯一键生成函数(绑定给 :row-key)
  1286. * 更健壮的容错:依次尝试 id -> goodsId -> itemId -> code -> itemCode -> 组合键(cname/itemName + code/itemCode + brandCode + typeNo/specs)
  1287. * @param {import('./types').ForecastFormMixinData['stockTableData'][number]} row
  1288. * @returns {string | number}
  1289. */
  1290. getRowUniqueKey(row) {
  1291. if (!row || typeof row !== 'object') return ''
  1292. /** @type {any} */
  1293. const anyRow = /** @type {any} */ (row)
  1294. // 简化:仅按 id -> goodsId -> code 顺序
  1295. if (anyRow.id !== undefined && anyRow.id !== null) return /** @type {any} */ (anyRow.id)
  1296. if (anyRow.goodsId !== undefined && anyRow.goodsId !== null) return /** @type {any} */ (anyRow.goodsId)
  1297. if (anyRow.code) return String(anyRow.code)
  1298. return ''
  1299. },
  1300. /**
  1301. * 更新物料下拉选项(过滤已在表格中的物料)
  1302. * @returns {void}
  1303. */
  1304. updateStockSelectOptions() {
  1305. try {
  1306. const table = Array.isArray(this.stockTableData) ? this.stockTableData : []
  1307. const source = Array.isArray(this.stockDescList) ? this.stockDescList : []
  1308. const idSet = new Set(table.filter(r => r && r.id !== undefined && r.id !== null).map(r => String(r.id)))
  1309. const goodsIdSet = new Set(table.filter(r => r && r.goodsId !== undefined && r.goodsId !== null).map(r => String(r.goodsId)))
  1310. const codeSet = new Set(table.filter(r => r && r.code).map(r => String(r.code)))
  1311. const options = source
  1312. .filter(item => {
  1313. const byId = item && item.id !== undefined && item.id !== null && idSet.has(String(item.id))
  1314. const byGoods = item && item.goodsId !== undefined && item.goodsId !== null && goodsIdSet.has(String(item.goodsId))
  1315. const byCode = item && item.code && codeSet.has(String(item.code))
  1316. return !(byId || byGoods || byCode)
  1317. })
  1318. .map(item => ({
  1319. label: /** @type {any} */ (item.cname || item.code || ''),
  1320. value: /** @type {any} */ (String(item.id))
  1321. }))
  1322. this.stockSelectOptions = options
  1323. // 如果当前选中不在可选项中,则清空
  1324. const hasSelected = options.some(opt => opt && opt.value === this.selectedStockId)
  1325. if (!hasSelected) {
  1326. this.selectedStockId = null
  1327. }
  1328. } catch (e) {
  1329. console.warn('更新物料下拉选项失败:', e)
  1330. }
  1331. },
  1332. /**
  1333. * 批量删除已选中的物料
  1334. * @returns {Promise<void>}
  1335. */
  1336. async handleBatchDelete() {
  1337. try {
  1338. const keys = new Set(Array.isArray(this.selectedRowKeys) ? this.selectedRowKeys : [])
  1339. if (keys.size === 0) {
  1340. this.$message && this.$message.warning('请先在下方表格选择要删除的物料')
  1341. return
  1342. }
  1343. await this.$confirm('确认删除已选中的物料吗?删除后可重新通过上方选择器导入。', '提示', {
  1344. type: 'warning',
  1345. confirmButtonText: '删除',
  1346. cancelButtonText: '取消'
  1347. })
  1348. const filtered = (Array.isArray(this.stockTableData) ? this.stockTableData : []).filter(row => {
  1349. const key = this.getRowUniqueKey(row)
  1350. return !keys.has(key)
  1351. })
  1352. this.stockTableData = filtered
  1353. // 清空选择并校正页码
  1354. this.selectedRowKeys = []
  1355. this.normalizePageAfterMutations()
  1356. // 刷新下拉选项
  1357. this.updateStockSelectOptions()
  1358. // [A] removed toast: batch deletion success message suppressed by requirement
  1359. } catch (e) {
  1360. if (e && e !== 'cancel') {
  1361. console.error('批量删除失败:', e)
  1362. this.$message && this.$message.error('批量删除失败,请稍后重试')
  1363. }
  1364. }
  1365. },
  1366. /**
  1367. * 页容量变更(el-pagination: size-change)
  1368. * @param {number} size
  1369. * @returns {void}
  1370. */
  1371. handleSizeChange(size) {
  1372. const newSize = Number(size) > 0 ? Number(size) : 10
  1373. this.pageSize = newSize
  1374. // 变更每页大小后,将页码重置为 1
  1375. this.currentPage = 1
  1376. // 同步当前页表格的选择回显
  1377. this.syncTableSelection()
  1378. },
  1379. /**
  1380. * 页码变更(el-pagination: current-change)
  1381. * @param {number} page
  1382. * @returns {void}
  1383. */
  1384. handlePageChange(page) {
  1385. const newPage = Number(page) > 0 ? Number(page) : 1
  1386. this.currentPage = newPage
  1387. // 同步当前页表格的选择回显
  1388. this.syncTableSelection()
  1389. },
  1390. /**
  1391. * 变更后校正页码(删除/导入后调用)
  1392. * @returns {void}
  1393. */
  1394. normalizePageAfterMutations() {
  1395. const total = this.total
  1396. const size = Math.max(1, Number(this.pageSize) || 10)
  1397. const maxPage = Math.max(1, Math.ceil(total / size))
  1398. if (this.currentPage > maxPage) {
  1399. this.currentPage = maxPage
  1400. }
  1401. if (this.currentPage < 1) {
  1402. this.currentPage = 1
  1403. }
  1404. // 校正页码后,确保当前页的表格勾选状态与 selectedRowKeys 保持一致
  1405. this.syncTableSelection()
  1406. },
  1407. /**
  1408. * 品牌变更处理
  1409. * @param {number} brandId - 品牌ID
  1410. * @returns {void}
  1411. * @this {ForecastFormMixinComponent & Vue}
  1412. */
  1413. handleBrandChange(/** @type {number} */ brandId) {
  1414. const selectedBrand = this.brandOptions.find(brand => /** @type {any} */ (brand).id === brandId)
  1415. if (selectedBrand) {
  1416. this.formData.brandId = brandId
  1417. this.formData.brandCode = /** @type {any} */ (selectedBrand).code
  1418. this.formData.brandName = /** @type {any} */ (selectedBrand).name
  1419. } else {
  1420. this.formData.brandId = null
  1421. this.formData.brandCode = ''
  1422. this.formData.brandName = ''
  1423. }
  1424. // 品牌切换后清空选中行,刷新下拉选项并同步表格选择
  1425. this.selectedRowKeys = []
  1426. this.$nextTick(() => {
  1427. this.syncTableSelection && this.syncTableSelection()
  1428. })
  1429. this.updateStockSelectOptions && this.updateStockSelectOptions()
  1430. },
  1431. /**
  1432. * 物料选择处理
  1433. * @description 处理MaterialSelect组件的物料选择事件
  1434. * @param {MaterialSelectData} materialData - 物料选择数据
  1435. * @returns {void}
  1436. * @this {ForecastFormMixinComponent & Vue}
  1437. */
  1438. handleMaterialSelected(/** @type {import('./types').MaterialSelectData} */ materialData) {
  1439. if (materialData && materialData.itemId) {
  1440. this.formData.itemId = Number(materialData.itemId)
  1441. this.formData.itemCode = materialData.itemCode
  1442. this.formData.itemName = materialData.itemName
  1443. this.formData.itemSpecs = materialData.specification || ''
  1444. // 获取当前库存
  1445. this.getCurrentInventory(materialData.itemId)
  1446. } else {
  1447. this.formData.itemId = null
  1448. this.formData.itemCode = ''
  1449. this.formData.itemName = ''
  1450. this.formData.itemSpecs = ''
  1451. this.currentInventory = null
  1452. }
  1453. },
  1454. /**
  1455. * 合并回显行的库存数量
  1456. * @this {ForecastFormMixinComponent & Vue}
  1457. * @description 使用 getUserLinkGoods 接口返回的库存数据,为编辑态回显的物料行补齐 storeInventory 字段
  1458. * @returns {Promise<void>}
  1459. */
  1460. async mergeEchoStoreInventory() {
  1461. try {
  1462. if (!Array.isArray(this.stockTableData) || this.stockTableData.length === 0) return
  1463. const res = await getUserLinkGoods()
  1464. const payload = res && res.data && res.data.data ? res.data.data : null
  1465. const stockList = (payload && payload.pjpfStockDescList) || []
  1466. if (!Array.isArray(stockList) || stockList.length === 0) return
  1467. // 在编辑模式下,确保"导入物料"的选择器有数据可选
  1468. // 不修改现有表格数据,仅补齐选择来源
  1469. this.stockDescList = stockList
  1470. this.stockSelectOptions = stockList.map(item => ({
  1471. label: item.cname,
  1472. value: item.id
  1473. }))
  1474. // 构建基于 goodsId 与 code 的索引映射
  1475. /** @type {Map<string, string|undefined>} */
  1476. const invByGoodsId = new Map()
  1477. /** @type {Map<string, string|undefined>} */
  1478. const invByCode = new Map()
  1479. stockList.forEach(s => {
  1480. const inv = (s && s.storeInventory !== undefined && s.storeInventory !== null && s.storeInventory !== '') ? String(s.storeInventory) : undefined
  1481. if (s && s.goodsId !== undefined && s.goodsId !== null) invByGoodsId.set(String(s.goodsId), inv)
  1482. if (s && s.code) invByCode.set(String(s.code), inv)
  1483. })
  1484. // 合并库存到现有表格数据(仅填充缺失的库存字段)
  1485. this.stockTableData = this.stockTableData.map(row => {
  1486. const hasInv = !(row.storeInventory === undefined || row.storeInventory === null || row.storeInventory === '')
  1487. if (hasInv) return row
  1488. const keyGoodsId = row && row.goodsId !== undefined && row.goodsId !== null ? String(row.goodsId) : ''
  1489. const keyCode = row && row.code ? String(row.code) : ''
  1490. const fromGoods = keyGoodsId ? invByGoodsId.get(keyGoodsId) : undefined
  1491. const fromCode = (!fromGoods && keyCode) ? invByCode.get(keyCode) : undefined
  1492. const value = (fromGoods !== undefined && fromGoods !== null && fromGoods !== '') ? fromGoods : ((fromCode !== undefined && fromCode !== null && fromCode !== '') ? fromCode : '0')
  1493. return { ...row, storeInventory: String(value) }
  1494. })
  1495. } catch (e) {
  1496. console.warn('回显库存合并失败:', e)
  1497. }
  1498. },
  1499. /**
  1500. * 新增模式:检查指定年月是否已有预测,并通过事件通知父组件控制保存按钮禁用
  1501. * @returns {Promise<void>}
  1502. */
  1503. async checkForecastByMonthAndEmit() {
  1504. try {
  1505. const year = typeof this.formData.year === 'string' ? parseInt(this.formData.year, 10) : Number(this.formData.year)
  1506. const month = Number(this.formData.month)
  1507. if (!year || !month) return
  1508. // 仅新增模式需要校验
  1509. if (this.isEdit) {
  1510. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SAVE_DISABLED_CHANGE, false)
  1511. return
  1512. }
  1513. const res = await getSalesForecastSummaryByMonth(year, month)
  1514. const hasData = !!(res && res.data && res.data.success && Array.isArray(res.data.data) && res.data.data.length > 0)
  1515. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SAVE_DISABLED_CHANGE, hasData)
  1516. } catch (e) {
  1517. // 异常时不阻塞新增,默认允许保存
  1518. this.$emit && this.$emit(FORECAST_FORM_EVENTS.SAVE_DISABLED_CHANGE, false)
  1519. }
  1520. },
  1521. /**
  1522. * 程序化同步当前页表格勾选状态到 selectedRowKeys
  1523. * @returns {void}
  1524. */
  1525. syncTableSelection() {
  1526. try {
  1527. /** @type {any} */
  1528. const table = this.$refs && this.$refs.stockTable
  1529. if (!table || typeof table.clearSelection !== 'function' || typeof table.toggleRowSelection !== 'function') return
  1530. this.selectionSyncing = true
  1531. this.$nextTick(() => {
  1532. try {
  1533. table.clearSelection()
  1534. const selectedSet = new Set(Array.isArray(this.selectedRowKeys) ? this.selectedRowKeys : [])
  1535. const rows = Array.isArray(this.pagedStockTableData) ? this.pagedStockTableData : []
  1536. rows.forEach(row => {
  1537. const key = this.getRowUniqueKey(row)
  1538. if (selectedSet.has(key)) {
  1539. table.toggleRowSelection(row, true)
  1540. }
  1541. })
  1542. } catch (e) {
  1543. console.warn('同步表格选择异常:', e)
  1544. } finally {
  1545. this.selectionSyncing = false
  1546. }
  1547. })
  1548. } catch (e) {
  1549. console.warn('syncTableSelection 初始化失败:', e)
  1550. this.selectionSyncing = false
  1551. }
  1552. },
  1553. }
  1554. }