material-detail-table.vue 35 KB

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