material-detail-mixin.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  1. /**
  2. * @fileoverview 物料明细表格组件
  3. * @description 基于AvueJS的物料明细数据展示和操作组件,支持分页、搜索、删除等功能
  4. * @typedef {import('./types').MaterialDetailTableComponent} MaterialDetailTableComponent
  5. */
  6. import { getMaterialDetailOption, DEFAULT_PAGINATION_CONFIG } from './material-detail-option'
  7. import {
  8. MaterialDetailStatus,
  9. getOrderItemStatusLabel as getMaterialDetailStatusLabel,
  10. getOrderItemStatusTagType as getMaterialDetailStatusTagType,
  11. getOrderItemStatusColor as getMaterialDetailStatusColor,
  12. MaterialDetailDataSource
  13. } from '@/constants/order'
  14. import { MATERIAL_DETAIL_EVENTS, DIALOG_EVENTS } from './events'
  15. import { getMaterialFullList } from '@/api/order/sales-order'
  16. import {
  17. formatAmount,
  18. formatFloatNumber,
  19. formatIntegerNumber,
  20. formatUnitPrice,
  21. formatTaxRate,
  22. preciseMultiply,
  23. preciseDivide,
  24. preciseRound,
  25. validateNumber,
  26. NUMBER_TYPES
  27. } from './number-format-utils'
  28. /**
  29. * @typedef {import('./types').MaterialDetailRecord} MaterialDetailRecord
  30. * @typedef {import('./types').MaterialUpdateEventData} MaterialUpdateEventData
  31. * @typedef {import('./types').MaterialDeleteEventData} MaterialDeleteEventData
  32. * @typedef {import('./types').MaterialDetailQueryParams} MaterialDetailQueryParams
  33. * @typedef {import('smallwei__avue/crud').AvueCrudOption} AvueCrudOption
  34. * @typedef {import('smallwei__avue/crud').AvueCrudColumn} AvueCrudColumn
  35. * @typedef {import('smallwei__avue/crud').PageOption} PageOption
  36. */
  37. // 使用@types/smallwei__avue/crud中的PageOption类型代替PaginationConfig
  38. /**
  39. * 组件数据类型定义
  40. * @typedef {Object} MaterialDetailTableData
  41. * @property {Partial<MaterialDetailRecord>} formData - 表单数据
  42. * @property {PageOption} page - 分页配置
  43. * @property {boolean} importDialogVisible - 导入弹窗显示状态
  44. */
  45. // 状态处理已统一使用明细管理中的工具函数,无需本地映射常量
  46. /**
  47. * 物料明细表格组件
  48. * @description 用于展示和编辑订单的物料明细信息,支持物料导入和实时编辑功能
  49. * 当物料数量、单价、税率等字段变更时,自动计算总金额和税额,并触发父组件重新计算订单总计
  50. * @emits {MaterialDetailRecord[]} material-import - 物料导入事件
  51. * @emits {Object} material-update - 物料明细更新事件,包含更新的行数据和索引
  52. * @emits {Object} material-delete - 物料明细删除事件
  53. * @emits {void} refresh - 刷新事件
  54. */
  55. export default {
  56. name: 'MaterialDetailTable',
  57. components: {},
  58. /**
  59. * 组件属性定义
  60. * @description 定义组件接收的外部属性
  61. */
  62. props: {
  63. /**
  64. * 是否为编辑模式 - 控制表格是否可编辑
  65. * @type {boolean}
  66. */
  67. editMode: {
  68. type: Boolean,
  69. default: false
  70. },
  71. /**
  72. * 订单ID - 关联的订单唯一标识符
  73. * @type {string|number|null}
  74. */
  75. orderId: {
  76. type: [String, Number],
  77. default: null,
  78. validator: (value) => value === null || value === undefined || (typeof value === 'string' && value.length > 0) || (typeof value === 'number' && value > 0)
  79. },
  80. /**
  81. * 物料明细列表 - 要展示的物料明细数据
  82. * @type {MaterialDetailRecord[]} 物料明细数据数组,每个元素包含物料的详细信息
  83. */
  84. materialDetails: {
  85. type: Array,
  86. required: true,
  87. default: () => [],
  88. validator: (value) => Array.isArray(value) && value.every(item =>
  89. typeof item === 'object' && item !== null &&
  90. typeof item.id === 'string' &&
  91. typeof item.itemCode === 'string'
  92. )
  93. }
  94. },
  95. /**
  96. * 组件数据
  97. * @returns {MaterialDetailTableData} 组件响应式数据对象
  98. * @this {MaterialDetailTableComponent}
  99. */
  100. data() {
  101. return {
  102. /**
  103. * 表单数据 - 当前编辑行的数据
  104. * @type {Partial<MaterialDetailRecord>} 物料明细表单数据对象
  105. */
  106. formData: {},
  107. /**
  108. * 分页配置 - AvueJS表格分页相关配置
  109. * @type {PaginationConfig} 包含currentPage、pageSize、total等属性的分页配置对象
  110. */
  111. page: {
  112. currentPage: 1,
  113. pageSize: DEFAULT_PAGINATION_CONFIG.pageSize,
  114. total: 0
  115. },
  116. /**
  117. * 选中的物料ID - 当前在下拉框中选中的物料ID
  118. * @type {string|null}
  119. */
  120. selectedMaterialId: null,
  121. /**
  122. * 物料选项列表 - 远程搜索返回的物料选项
  123. * @type {ItemRecord[]}
  124. */
  125. materialOptions: [],
  126. /**
  127. * 物料搜索加载状态 - 控制远程搜索时的加载状态
  128. * @type {boolean}
  129. */
  130. materialLoading: false,
  131. /**
  132. * 搜索防抖定时器 - 用于防抖处理远程搜索
  133. * @type {number|null}
  134. */
  135. searchTimer: null,
  136. /**
  137. * 事件常量
  138. */
  139. DIALOG_EVENTS,
  140. /**
  141. * 正在编辑的行数据 - 用于记录编辑前的状态
  142. * @type {MaterialDetailRecord|null}
  143. */
  144. editingRow: null,
  145. /**
  146. * 正在编辑的属性名 - 用于记录当前编辑的字段
  147. * @type {string|null}
  148. */
  149. editingProp: null
  150. }
  151. },
  152. /**
  153. * 计算属性
  154. * @this {MaterialDetailTableComponent}
  155. */
  156. computed: {
  157. /**
  158. * 表格配置选项 - 获取AvueJS表格的配置对象
  159. * @returns {AvueCrudOption} AvueJS表格配置对象,根据编辑模式配置
  160. */
  161. tableOption() {
  162. return getMaterialDetailOption(this.editMode)
  163. },
  164. /**
  165. * 当前页显示的数据 - 根据分页配置计算当前页应显示的数据
  166. * @returns {MaterialDetailRecord[]} 当前页的物料明细数据
  167. */
  168. currentPageData() {
  169. const { currentPage, pageSize } = this.page
  170. const startIndex = (currentPage - 1) * pageSize
  171. const endIndex = startIndex + pageSize
  172. return this.materialDetails.slice(startIndex, endIndex)
  173. }
  174. },
  175. /**
  176. * 监听器
  177. * @this {MaterialDetailTableComponent}
  178. */
  179. watch: {
  180. /**
  181. * 监听物料明细变化
  182. * @param {MaterialDetailRecord[]} newVal - 新的物料明细列表
  183. * @returns {void}
  184. */
  185. materialDetails: {
  186. handler(newVal) {
  187. this.page.total = newVal.length
  188. },
  189. immediate: true
  190. }
  191. },
  192. /**
  193. * 组件方法
  194. * @this {MaterialDetailTableComponent}
  195. */
  196. methods: {
  197. /**
  198. * 验证整数输入
  199. * @param {string} value - 输入值
  200. * @param {Object} row - 当前行数据
  201. * @param {string} field - 字段名
  202. * @param {number} min - 最小值
  203. * @param {number} max - 最大值
  204. */
  205. validateIntegerInput(value, row, field, min = 0, max = 999999) {
  206. // 允许空值和部分输入(如正在输入的数字)
  207. if (value === '' || value === '-') {
  208. return
  209. }
  210. // 移除所有非数字字符(除了负号)
  211. let cleanValue = value.replace(/[^-\d]/g, '')
  212. // 确保负号只能在开头
  213. if (cleanValue.indexOf('-') > 0) {
  214. cleanValue = cleanValue.replace(/-/g, '')
  215. }
  216. // 如果有值,转换为整数
  217. if (cleanValue !== '' && cleanValue !== '-') {
  218. const numValue = parseInt(cleanValue, 10)
  219. // 检查范围
  220. if (numValue < min) {
  221. row[field] = min
  222. } else if (numValue > max) {
  223. row[field] = max
  224. } else {
  225. row[field] = numValue
  226. }
  227. }
  228. },
  229. /**
  230. * 验证浮点数输入(输入时验证)
  231. * @param {string} value - 输入值
  232. * @param {Object} row - 当前行数据
  233. * @param {string} field - 字段名
  234. * @param {number} min - 最小值
  235. * @param {number} max - 最大值
  236. */
  237. validateFloatInput(value, row, field, min = 0, max = 999999.99) {
  238. // 允许空值和部分输入(包括单独的小数点、负号等)
  239. if (value === '' || value === '-' || value === '.' || value === '-.') {
  240. row[field] = value
  241. return
  242. }
  243. // 移除无效字符,只保留数字、小数点和负号
  244. let cleanValue = value.replace(/[^-\d.]/g, '')
  245. // 确保负号只能在开头
  246. if (cleanValue.indexOf('-') > 0) {
  247. cleanValue = cleanValue.replace(/-/g, '')
  248. }
  249. // 确保只有一个小数点
  250. const parts = cleanValue.split('.')
  251. if (parts.length > 2) {
  252. cleanValue = parts[0] + '.' + parts.slice(1).join('')
  253. }
  254. // 限制小数位数为2位(但允许继续输入)
  255. if (parts.length === 2 && parts[1].length > 2) {
  256. cleanValue = parts[0] + '.' + parts[1].substring(0, 2)
  257. }
  258. // 更新字段值,但不进行范围检查(留到blur时处理)
  259. row[field] = cleanValue
  260. },
  261. /**
  262. * 验证并格式化浮点数(失焦时验证)
  263. * @param {Object} row - 当前行数据
  264. * @param {string} field - 字段名
  265. * @param {number} min - 最小值
  266. * @param {number} max - 最大值
  267. * @param {number} precision - 小数位数,默认2位
  268. */
  269. validateAndFormatFloatOnBlur(row, field, min = 0, max = 999999.99, precision = 2) {
  270. const value = row[field]
  271. // 如果是空值或无效输入,设置为最小值
  272. if (value === '' || value === '.' || value === '-' || value === '-.' || isNaN(parseFloat(value))) {
  273. row[field] = min
  274. return
  275. }
  276. const numValue = parseFloat(value)
  277. // 范围检查
  278. if (numValue < min) {
  279. row[field] = min
  280. } else if (numValue > max) {
  281. row[field] = max
  282. } else {
  283. // 格式化为指定小数位数
  284. const multiplier = Math.pow(10, precision)
  285. const roundedValue = Math.round(numValue * multiplier) / multiplier
  286. row[field] = roundedValue
  287. }
  288. },
  289. /**
  290. * 判断行是否可编辑
  291. * @description 根据数据来源判断物料明细行是否允许编辑,远程数据(订单ID获取)不可编辑
  292. * @param {MaterialDetailRecord} row - 物料明细行数据
  293. * @returns {boolean} 是否可编辑,true表示可编辑,false表示不可编辑
  294. */
  295. isRowEditable(row) {
  296. // 如果没有数据来源信息,默认可编辑
  297. if (!row || !row.dataSource) {
  298. return true
  299. }
  300. // 只有导入的物料可以编辑,远程数据(订单ID获取)不可编辑
  301. return row.dataSource === MaterialDetailDataSource.IMPORTED
  302. },
  303. /**
  304. * 远程搜索物料
  305. * @description 根据关键词远程搜索物料数据,支持防抖处理
  306. * @param {string} query - 搜索关键词
  307. * @returns {void}
  308. */
  309. remoteSearchMaterial(query) {
  310. // 清除之前的定时器
  311. if (this.searchTimer) {
  312. clearTimeout(this.searchTimer)
  313. }
  314. // 如果查询为空,清空选项
  315. if (!query) {
  316. this.materialOptions = []
  317. return
  318. }
  319. // 设置防抖定时器
  320. this.searchTimer = setTimeout(async () => {
  321. await this.searchMaterials(query)
  322. }, 300)
  323. },
  324. /**
  325. * 搜索物料数据
  326. * @description 调用API搜索物料数据
  327. * @param {string} keyword - 搜索关键词
  328. * @returns {Promise<void>}
  329. * @throws {Error} 当API调用失败时抛出异常
  330. */
  331. async searchMaterials(keyword) {
  332. try {
  333. this.materialLoading = true
  334. const response = await getMaterialFullList({
  335. itemName: keyword
  336. })
  337. if (response?.data?.success && response.data.data) {
  338. // 转换API返回的字段名称为组件所需的格式
  339. // getMaterialFullList返回的是SalesOrderItemListRecord[]数组
  340. this.materialOptions = response.data.data.map(item => ({
  341. id: item.id,
  342. itemId: item.Item_ID,
  343. itemCode: item.Item_Code,
  344. itemName: item.Item_Name,
  345. specs: item.Item_PECS || '',
  346. unit: item.InventoryInfo_Name,
  347. mainItemCategoryName: item.MainItemCategory_Name,
  348. mainItemCategoryId: item.MainItemCategory_ID,
  349. mainItemCategoryCode: item.MainItemCategory_Code,
  350. unitPrice: item.Item_Price || '0',
  351. description: item.Item_Description || '',
  352. warehouseId: item.Warehouse_ID,
  353. warehouseCode: item.Warehouse_Code,
  354. warehouseName: item.Warehouse_Name,
  355. orgId: item.ORG_ID,
  356. orgCode: item.ORG_CODE,
  357. orgName: item.ORG_NAME,
  358. // 保留原始数据以备后用
  359. _raw: item
  360. }))
  361. } else {
  362. this.materialOptions = []
  363. const errorMsg = response?.data?.msg || '搜索物料失败'
  364. this.$message.warning(errorMsg)
  365. }
  366. } catch (error) {
  367. this.materialOptions = []
  368. this.$message.error('网络错误,搜索物料失败')
  369. } finally {
  370. this.materialLoading = false
  371. }
  372. },
  373. /**
  374. * 处理导入选中物料
  375. * @description 将选中的物料导入到物料明细表中
  376. * @returns {void}
  377. */
  378. handleImportSelectedMaterial() {
  379. if (!this.selectedMaterialId) {
  380. this.$message.warning('请先选择要导入的物料')
  381. return
  382. }
  383. // 查找选中的物料数据
  384. const selectedMaterial = this.materialOptions.find(item => item.id === this.selectedMaterialId)
  385. if (!selectedMaterial) {
  386. this.$message.warning('未找到选中的物料数据')
  387. return
  388. }
  389. // 检查是否已存在相同物料
  390. const existingMaterial = this.materialDetails.find(item => item.itemCode === selectedMaterial.itemCode)
  391. if (existingMaterial) {
  392. this.$message.warning(`物料 ${selectedMaterial.itemName} 已存在,请勿重复导入`)
  393. return
  394. }
  395. // 构造物料明细数据
  396. let materialDetail = this.prepareMaterialDetailData(selectedMaterial)
  397. // 导入时自动计算金额
  398. materialDetail = this.calculateAmounts(materialDetail)
  399. // 触发导入事件
  400. this.$emit(MATERIAL_DETAIL_EVENTS.MATERIAL_IMPORT, [materialDetail])
  401. this.$emit(MATERIAL_DETAIL_EVENTS.REFRESH)
  402. // 清空选择
  403. this.selectedMaterialId = null
  404. this.materialOptions = []
  405. },
  406. /**
  407. * 准备物料明细数据
  408. * @description 将选中的物料数据转换为物料明细表所需的格式
  409. * @param {import('@/api/types/order').SalesOrderItemListRecord} material - 物料数据(来自getMaterialFullList API)
  410. * @returns {MaterialDetailRecord} 格式化后的物料明细数据
  411. * @private
  412. */
  413. prepareMaterialDetailData(material) {
  414. return {
  415. itemId: material.itemId,
  416. itemCode: material.itemCode,
  417. itemName: material.itemName,
  418. specs: material.specs || '',
  419. specification: material.specs || '',
  420. unit: material.unit || '',
  421. mainItemCategoryName: material.mainItemCategoryName || material.MainItemCategory_Name || '',
  422. mainItemCategoryId: material.mainItemCategoryId || material.MainItemCategory_ID,
  423. mainItemCategoryCode: material.mainItemCategoryCode || material.MainItemCategory_Code || '',
  424. description: material.description || '',
  425. warehouseId: material.warehouseId,
  426. warehouseCode: material.warehouseCode,
  427. warehouseName: material.warehouseName,
  428. orgId: material.orgId,
  429. orgCode: material.orgCode,
  430. orgName: material.orgName,
  431. unitPrice: material.unitPrice || 0,
  432. orderQuantity: 1,
  433. confirmQuantity: 1,
  434. availableQuantity: 0,
  435. taxRate: 0,
  436. taxAmount: 0,
  437. totalAmount: 0,
  438. itemStatus: MaterialDetailStatus.UNCONFIRMED,
  439. dataSource: MaterialDetailDataSource.IMPORTED,
  440. isDeletable: true,
  441. remark: ''
  442. }
  443. },
  444. /**
  445. * 生成唯一ID
  446. * @description 生成物料明细的唯一标识符
  447. * @returns {string} 唯一ID
  448. * @private
  449. */
  450. generateUniqueId() {
  451. return 'material_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
  452. },
  453. /**
  454. * 处理表格刷新事件
  455. * @description 触发刷新事件,通知父组件重新加载数据
  456. * @returns {void}
  457. * @emits refresh
  458. */
  459. handleRefresh() {
  460. this.$emit(MATERIAL_DETAIL_EVENTS.REFRESH)
  461. },
  462. /**
  463. * 处理分页页码变化事件
  464. * @description 当用户切换页码时触发,更新当前页码
  465. * @param {number} currentPage - 新的页码,从1开始
  466. * @returns {void}
  467. */
  468. handleCurrentChange(currentPage) {
  469. this.page.currentPage = currentPage
  470. },
  471. /**
  472. * 处理分页大小变化事件
  473. * @description 当用户改变每页显示条数时触发,重置到第一页
  474. * @param {number} pageSize - 新的每页显示条数
  475. * @returns {void}
  476. */
  477. handleSizeChange(pageSize) {
  478. this.page.pageSize = pageSize
  479. this.page.currentPage = 1
  480. },
  481. /**
  482. * 格式化浮点数显示
  483. * @description 格式化浮点数为4位小数的字符串
  484. * @param {number|string|null|undefined} value - 数值
  485. * @returns {string} 格式化后的字符串
  486. */
  487. formatFloatNumber(value) {
  488. return formatFloatNumber(value)
  489. },
  490. /**
  491. * 格式化金额显示
  492. * @description 格式化金额为带货币符号的字符串
  493. * @param {number|string|null|undefined} amount - 金额数值
  494. * @param {boolean} withSymbol - 是否显示货币符号
  495. * @returns {string} 格式化后的金额字符串
  496. */
  497. formatAmount(amount, withSymbol = true) {
  498. return formatAmount(amount, withSymbol)
  499. },
  500. formatUnitPrice,
  501. formatTaxRate,
  502. /**
  503. * 格式化整数显示
  504. * @description 格式化整数为字符串
  505. * @param {number|string|null|undefined} value - 整数数值
  506. * @returns {string} 格式化后的整数字符串
  507. */
  508. formatIntegerNumber(value) {
  509. return formatIntegerNumber(value)
  510. },
  511. /**
  512. * 获取状态标签类型
  513. * @description 根据物料明细状态值返回对应的Element UI标签类型
  514. * @param {typeof MaterialDetailStatus[keyof typeof MaterialDetailStatus]} itemStatus - 物料明细状态值
  515. * @returns {string} Element UI标签类型
  516. * @example
  517. * getStatusTagType(0) // 返回 'warning'
  518. * getStatusTagType(1) // 返回 'success'
  519. */
  520. getStatusTagType(itemStatus) {
  521. return getMaterialDetailStatusTagType(itemStatus)
  522. },
  523. /**
  524. * 获取状态文本
  525. * @description 根据物料明细状态值返回对应的中文描述
  526. * @param {number} itemStatus - 物料明细状态值
  527. * @returns {string} 状态的中文描述文本
  528. * @example
  529. * getStatusText(0) // 返回 '待确认'
  530. * getStatusText(1) // 返回 '已确认'
  531. */
  532. getStatusText(itemStatus) {
  533. return getMaterialDetailStatusLabel(itemStatus)
  534. },
  535. /**
  536. * 处理删除物料操作
  537. * @description 删除指定的物料明细记录,仅允许删除可删除的物料
  538. * @param {MaterialDetailRecord} row - 要删除的物料明细记录
  539. * @param {number} index - 记录在当前页的索引位置
  540. * @returns {void}
  541. * @emits material-delete
  542. */
  543. async handleDeleteMaterial(row, index) {
  544. try {
  545. await this.$confirm(
  546. `确定要删除物料 "${row.itemName}" 吗?`,
  547. '删除确认',
  548. {
  549. confirmButtonText: '确定',
  550. cancelButtonText: '取消',
  551. type: 'warning'
  552. }
  553. )
  554. // 触发删除事件,传递物料记录和索引
  555. this.$emit(MATERIAL_DETAIL_EVENTS.MATERIAL_DELETE, { row, index })
  556. this.$message.success('物料删除成功')
  557. } catch (error) {
  558. // 用户取消删除操作
  559. if (error !== 'cancel') {
  560. this.$message.error('删除操作失败')
  561. }
  562. }
  563. },
  564. /**
  565. * 处理表格行删除事件
  566. * @description AvueJS表格的删除事件处理器,委托给自定义删除方法
  567. * @param {MaterialDetailRecord} row - 要删除的行数据
  568. * @param {number} index - 行索引
  569. * @returns {void}
  570. */
  571. handleRowDelete(row, index) {
  572. this.handleDeleteMaterial(row, index)
  573. },
  574. /**
  575. * 处理行更新事件
  576. * @description 当用户编辑表格行数据时触发,执行自动计算逻辑
  577. * @param {MaterialDetailRecord} row - 更新后的行数据
  578. * @param {number} index - 行索引
  579. * @param {boolean} done - 完成回调函数
  580. * @returns {void}
  581. * @emits material-update
  582. */
  583. async handleRowUpdate(row, index, done) {
  584. try {
  585. // 执行自动计算
  586. const calculatedRow = this.calculateAmounts(row)
  587. // 触发更新事件,传递计算后的数据
  588. this.$emit(MATERIAL_DETAIL_EVENTS.MATERIAL_UPDATE, { row: calculatedRow, index })
  589. // 完成编辑
  590. done(calculatedRow)
  591. this.$message.success('物料明细更新成功')
  592. } catch (error) {
  593. this.$message.error('更新失败:' + error.message)
  594. done(false)
  595. }
  596. },
  597. /**
  598. * 处理订单数量失焦事件
  599. * @description 当订单数量输入框失焦时,触发数量变更处理
  600. * @param {MaterialDetailRecord} row - 行数据
  601. * @param {number} index - 行索引
  602. * @returns {void}
  603. */
  604. handleQuantityBlur(row, index) {
  605. // 如果 index 无效,尝试通过 row 数据找到正确的索引
  606. const actualIndex = this.findRowIndex(row, index)
  607. this.handleQuantityChange(row, actualIndex)
  608. },
  609. /**
  610. * 处理订单数量变更
  611. * @description 当订单数量发生变化时,自动计算总金额和税额,并触发父组件重新计算订单总计
  612. * @param {MaterialDetailRecord} row - 行数据
  613. * @param {number} index - 行索引
  614. * @returns {void}
  615. */
  616. handleQuantityChange(row, index) {
  617. const calculatedRow = this.calculateAmounts(row)
  618. Object.assign(row, calculatedRow)
  619. this.$emit(MATERIAL_DETAIL_EVENTS.MATERIAL_UPDATE, { row, index })
  620. },
  621. /**
  622. * 处理税率变更
  623. * @description 当税率发生变化时,重新计算税额,并触发父组件重新计算订单总计
  624. * @param {MaterialDetailRecord} row - 行数据
  625. * @param {number} index - 行索引
  626. * @returns {void}
  627. */
  628. handleTaxRateChange(row, index) {
  629. this.calculateTaxAmount(row)
  630. this.$emit('material-update', { row, index })
  631. },
  632. /**
  633. * 处理单价失焦事件
  634. * @description 当单价输入框失焦时,先格式化数值,再计算总金额和税额,并触发父组件重新计算订单总计
  635. * @param {MaterialDetailRecord} row - 行数据
  636. * @param {number} index - 行索引
  637. * @returns {void}
  638. */
  639. handleUnitPriceBlur(row, index) {
  640. // 先格式化数值
  641. this.validateAndFormatFloatOnBlur(row, 'unitPrice')
  642. // 如果 index 无效,尝试通过 row 数据找到正确的索引
  643. const actualIndex = this.findRowIndex(row, index)
  644. // 再处理单价变更
  645. this.handleUnitPriceChange(row, actualIndex)
  646. },
  647. /**
  648. * 处理单价变更
  649. * @description 当单价发生变化时,自动计算总金额和税额,并触发父组件重新计算订单总计
  650. * @param {MaterialDetailRecord} row - 行数据
  651. * @param {number} index - 行索引
  652. * @returns {void}
  653. */
  654. handleUnitPriceChange(row, index) {
  655. const calculatedRow = this.calculateAmounts(row)
  656. Object.assign(row, calculatedRow)
  657. this.$emit('material-update', { row, index })
  658. },
  659. /**
  660. * 处理税额变更
  661. * @description 当税额手动修改时,反推税率,并触发父组件重新计算订单总计
  662. * @param {MaterialDetailRecord} row - 行数据
  663. * @param {number} index - 行索引
  664. * @returns {void}
  665. */
  666. handleTaxAmountChange(row, index) {
  667. // 当税额手动修改时,反推税率
  668. if (row.totalAmount && row.totalAmount > 0) {
  669. row.taxRate = ((row.taxAmount || 0) / row.totalAmount * 100).toFixed(2)
  670. }
  671. this.$emit('material-update', { row, index })
  672. },
  673. /**
  674. * 处理总金额变更
  675. * @description 当总金额手动修改时,重新计算税额,并触发父组件重新计算订单总计
  676. * @param {MaterialDetailRecord} row - 行数据
  677. * @param {number} index - 行索引
  678. * @returns {void}
  679. */
  680. handleTotalAmountChange(row, index) {
  681. // 当总金额手动修改时,重新计算税额
  682. this.calculateTaxAmount(row)
  683. this.$emit('material-update', { row, index })
  684. },
  685. /**
  686. * 处理单元格编辑开始事件
  687. * @description 当用户开始编辑单元格时触发
  688. * @param {MaterialDetailRecord} row - 编辑的行数据
  689. * @param {string} prop - 编辑的属性名
  690. * @param {*} value - 当前值
  691. * @returns {void}
  692. */
  693. handleCellEditStart(row, prop, value) {
  694. // 记录编辑前的值,用于计算变化
  695. this.editingRow = { ...row }
  696. this.editingProp = prop
  697. },
  698. /**
  699. * 处理单元格编辑结束事件
  700. * @description 当用户结束编辑单元格时触发,执行实时计算
  701. * @param {MaterialDetailRecord} row - 编辑后的行数据
  702. * @param {string} prop - 编辑的属性名
  703. * @param {*} value - 新值
  704. * @returns {void}
  705. */
  706. handleCellEditEnd(row, prop, value) {
  707. // 如果编辑的是影响计算的字段,执行自动计算
  708. if (['orderQuantity', 'unitPrice', 'taxRate'].includes(prop)) {
  709. const calculatedRow = this.calculateAmounts(row)
  710. // 更新行数据
  711. Object.assign(row, calculatedRow)
  712. // 触发更新事件
  713. this.$emit(MATERIAL_DETAIL_EVENTS.MATERIAL_UPDATE, { row: calculatedRow, index: this.getCurrentRowIndex(row) })
  714. }
  715. },
  716. /**
  717. * 自动计算金额
  718. * @description 根据订单数量、单价和税率自动计算总金额和税额,使用精确计算避免浮点数精度问题
  719. * @param {MaterialDetailRecord} row - 物料明细记录
  720. * @returns {MaterialDetailRecord} 计算后的物料明细记录
  721. */
  722. calculateAmounts(row) {
  723. const calculatedRow = { ...row }
  724. // 验证并获取数值
  725. const quantityValidation = validateNumber(calculatedRow.orderQuantity)
  726. const priceValidation = validateNumber(calculatedRow.unitPrice)
  727. const rateValidation = validateNumber(calculatedRow.taxRate)
  728. const orderQuantity = quantityValidation.isValid ? Math.round(quantityValidation.value) : 0
  729. const unitPrice = priceValidation.isValid ? priceValidation.value : 0
  730. const taxRate = rateValidation.isValid ? rateValidation.value : 0
  731. // 使用精确计算:订单数量 * 单价
  732. const totalAmount = preciseMultiply(orderQuantity, unitPrice)
  733. calculatedRow.totalAmount = preciseRound(totalAmount, 2)
  734. // 使用精确计算:总金额 * 税率 / 100
  735. const taxAmount = preciseMultiply(totalAmount, preciseDivide(taxRate, 100))
  736. calculatedRow.taxAmount = preciseRound(taxAmount, 2)
  737. return calculatedRow
  738. },
  739. /**
  740. * 计算税额
  741. * @description 根据总金额和税率计算税额,使用精确计算避免浮点数精度问题
  742. * @param {MaterialDetailRecord} row - 物料明细记录
  743. * @returns {void}
  744. */
  745. calculateTaxAmount(row) {
  746. const amountValidation = validateNumber(row.totalAmount)
  747. const rateValidation = validateNumber(row.taxRate)
  748. if (amountValidation.isValid && rateValidation.isValid) {
  749. const totalAmount = amountValidation.value
  750. const taxRate = rateValidation.value
  751. // 使用精确计算:总金额 * 税率 / 100
  752. const taxAmount = preciseMultiply(totalAmount, preciseDivide(taxRate, 100))
  753. row.taxAmount = preciseRound(taxAmount, 2)
  754. } else {
  755. row.taxAmount = 0
  756. }
  757. },
  758. /**
  759. * 获取当前行索引
  760. * @description 根据行数据获取在当前页中的索引
  761. * @param {MaterialDetailRecord} row - 行数据
  762. * @returns {number} 行索引
  763. */
  764. getCurrentRowIndex(row) {
  765. return this.currentPageData.findIndex(item => item.id === row.id)
  766. },
  767. /**
  768. * 查找行索引
  769. * @description 根据行数据查找在物料明细列表中的正确索引
  770. * @param {MaterialDetailRecord} row - 行数据
  771. * @param {number} providedIndex - 提供的索引
  772. * @returns {number} 实际索引
  773. */
  774. findRowIndex(row, providedIndex) {
  775. // 如果提供的索引有效,直接使用
  776. if (providedIndex >= 0 && providedIndex < this.materialDetails.length) {
  777. return providedIndex
  778. }
  779. // 否则通过行数据查找索引
  780. const index = this.materialDetails.findIndex(item => {
  781. // 优先使用 id 进行匹配
  782. if (row.id && item.id) {
  783. return row.id === item.id
  784. }
  785. // 如果没有 id,使用物料编码进行匹配
  786. if (row.itemCode && item.itemCode) {
  787. return row.itemCode === item.itemCode
  788. }
  789. // 最后使用对象引用进行匹配
  790. return row === item
  791. })
  792. return index >= 0 ? index : -1
  793. }
  794. },
  795. /**
  796. * 组件销毁前的清理工作
  797. * @description 清除定时器,避免内存泄漏
  798. */
  799. beforeDestroy() {
  800. if (this.searchTimer) {
  801. clearTimeout(this.searchTimer)
  802. this.searchTimer = null
  803. }
  804. }
  805. }