material-detail-table.vue 37 KB

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