BatchChangeDialog.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957
  1. <template>
  2. <el-dialog
  3. :title="dialogTitle"
  4. :visible.sync="visible"
  5. width="1050px"
  6. :close-on-click-modal="false"
  7. @close="reset"
  8. >
  9. <template v-if="mode === 'records'">
  10. <ele-pro-table
  11. ref="recordTable"
  12. :columns="recordColumns"
  13. :datasource="recordSource"
  14. height="450px"
  15. :pageSize="10"
  16. >
  17. <template v-slot:status="{ row }">
  18. <el-tag :type="statusType(row.verifyStatus)">
  19. {{ statusText(row.verifyStatus) }}
  20. </el-tag>
  21. </template>
  22. <template v-slot:action="{ row }">
  23. <el-button type="text" @click="showDetail(row)">详情</el-button>
  24. <el-button
  25. v-if="row.verifyStatus === 0"
  26. type="text"
  27. :loading="recordActionId === row.id"
  28. @click="submitRecord(row)"
  29. >提交</el-button
  30. >
  31. <el-button
  32. v-if="row.verifyStatus === 1"
  33. type="text"
  34. :loading="recordActionId === row.id"
  35. @click="withdrawRecord(row)"
  36. >撤回</el-button
  37. >
  38. </template>
  39. </ele-pro-table>
  40. </template>
  41. <div v-else v-loading="sourceLoading">
  42. <el-alert
  43. v-if="mode === 'merge'"
  44. title="仅允许同仓库、批次号、物品属性、供应商和价格完全一致的库存合批,审批通过后生成新系统批次。"
  45. type="info"
  46. :closable="false"
  47. />
  48. <el-alert
  49. v-else
  50. title="包装明细必须整行分配;锁定明细按锁定单分配,关联包装时自动跟随包装目标。"
  51. type="info"
  52. :closable="false"
  53. />
  54. <el-alert
  55. v-if="pendingApproveId"
  56. title="申请已保存,当前仅续提审批,不会重复生成审核代码。"
  57. type="warning"
  58. :closable="false"
  59. class="saved-alert"
  60. />
  61. <div class="section-title">来源库存</div>
  62. <el-table :data="sources" size="mini" border>
  63. <el-table-column
  64. prop="systemBatchNo"
  65. label="来源系统批次"
  66. min-width="150"
  67. />
  68. <el-table-column prop="batchNo" label="业务批次" />
  69. <el-table-column prop="warehouseName" label="仓库" />
  70. <el-table-column prop="categoryCode" label="物品编码" />
  71. <el-table-column prop="stockCountBase" label="计量数量" />
  72. <el-table-column prop="packingQuantity" label="包装数量" />
  73. <el-table-column prop="weight" label="重量" />
  74. </el-table>
  75. <template v-if="mode === 'split'">
  76. <div class="section-title">目标子批次</div>
  77. <el-table :data="targetRows" size="mini" border>
  78. <el-table-column label="目标" width="90">
  79. <template slot-scope="{ row }"
  80. >子批次 {{ row.targetDetailId }}</template
  81. >
  82. </el-table-column>
  83. <el-table-column label="系统批次号" width="130">
  84. <template>审批后生成</template>
  85. </el-table-column>
  86. <el-table-column label="计量数量">
  87. <template slot-scope="{ row }">
  88. <el-input
  89. v-model="row.target.measureQuantity"
  90. maxlength="19"
  91. :disabled="Boolean(pendingApproveId)"
  92. size="mini"
  93. placeholder="请输入"
  94. />
  95. </template>
  96. </el-table-column>
  97. <el-table-column label="包装数量">
  98. <template slot-scope="{ row }">
  99. <span v-if="hasPacking">{{ row.packageQuantity }}</span>
  100. <el-input
  101. v-else
  102. v-model="row.target.packageQuantity"
  103. maxlength="19"
  104. :disabled="Boolean(pendingApproveId)"
  105. size="mini"
  106. placeholder="请输入"
  107. />
  108. </template>
  109. </el-table-column>
  110. <el-table-column label="重量">
  111. <template slot-scope="{ row }">
  112. <el-input
  113. v-model="row.target.weight"
  114. maxlength="19"
  115. :disabled="Boolean(pendingApproveId)"
  116. size="mini"
  117. placeholder="请输入"
  118. />
  119. </template>
  120. </el-table-column>
  121. <el-table-column prop="lockQuantity" label="锁库数量" />
  122. <el-table-column width="70">
  123. <template slot-scope="{ row }">
  124. <el-button
  125. type="text"
  126. :disabled="Boolean(pendingApproveId)"
  127. @click="removeTarget(row.targetDetailId)"
  128. >删除</el-button
  129. >
  130. </template>
  131. </el-table-column>
  132. </el-table>
  133. <el-button
  134. type="text"
  135. icon="el-icon-plus"
  136. :disabled="Boolean(pendingApproveId)"
  137. @click="addTarget"
  138. >新增子批次</el-button
  139. >
  140. </template>
  141. <div v-else class="merge-summary">
  142. 合并后计量数量:{{ total('stockCountBase') }},包装数量:{{
  143. total('packingQuantity')
  144. }},重量:{{ total('weight') }};存货周期取来源最长值。
  145. </div>
  146. <template v-if="packingRows.length">
  147. <div class="section-title">整包装分配</div>
  148. <el-table :data="packingRows" size="mini" border max-height="260">
  149. <el-table-column
  150. prop="systemBatchNo"
  151. label="来源系统批次"
  152. min-width="140"
  153. />
  154. <el-table-column prop="packageNo" label="包装编码" min-width="130" />
  155. <el-table-column prop="measureQuantity" label="计量数量" />
  156. <el-table-column prop="packageQuantity" label="包装数量" />
  157. <el-table-column prop="weight" label="重量" />
  158. <el-table-column prop="lockCountBase" label="锁库数量" />
  159. <el-table-column label="目标子批次" min-width="150">
  160. <template slot-scope="{ row }">
  161. <el-select
  162. v-model="row.targetDetailId"
  163. size="mini"
  164. :disabled="mode === 'merge' || Boolean(pendingApproveId)"
  165. placeholder="请选择"
  166. @change="assignPackingTarget(row, $event)"
  167. >
  168. <el-option
  169. v-for="target in targets"
  170. :key="target.targetDetailId"
  171. :label="targetLabel(target.targetDetailId)"
  172. :value="target.targetDetailId"
  173. />
  174. </el-select>
  175. </template>
  176. </el-table-column>
  177. </el-table>
  178. </template>
  179. <template v-if="lockRows.length">
  180. <div class="section-title">锁定单分配</div>
  181. <el-table :data="lockRows" size="mini" border max-height="260">
  182. <el-table-column prop="orderNo" label="锁定单号" min-width="140" />
  183. <el-table-column
  184. prop="lockOrderDetailId"
  185. label="锁定明细ID"
  186. min-width="110"
  187. />
  188. <el-table-column prop="quantity" label="锁定数量" />
  189. <el-table-column label="关联包装" min-width="180">
  190. <template slot-scope="{ row }">{{ packingNames(row) }}</template>
  191. </el-table-column>
  192. <el-table-column label="目标子批次" min-width="150">
  193. <template slot-scope="{ row }">
  194. <el-select
  195. v-model="row.targetDetailId"
  196. size="mini"
  197. :disabled="mode === 'merge' || Boolean(pendingApproveId)"
  198. placeholder="请选择"
  199. @change="assignLockOrderTarget(row, $event)"
  200. >
  201. <el-option
  202. v-for="target in targets"
  203. :key="target.targetDetailId"
  204. :label="targetLabel(target.targetDetailId)"
  205. :value="target.targetDetailId"
  206. />
  207. </el-select>
  208. <div v-if="row.targetConflict" class="error-text"
  209. >关联包装不能跨目标</div
  210. >
  211. </template>
  212. </el-table-column>
  213. </el-table>
  214. </template>
  215. <el-input
  216. v-model="remark"
  217. type="textarea"
  218. :rows="2"
  219. :disabled="Boolean(pendingApproveId)"
  220. placeholder="申请说明(可选)"
  221. class="remark"
  222. />
  223. </div>
  224. <span slot="footer">
  225. <el-button @click="visible = false">取 消</el-button>
  226. <el-button
  227. v-if="mode !== 'records'"
  228. type="primary"
  229. :loading="saving"
  230. :disabled="sourceLoading"
  231. @click="submitApply"
  232. >{{ pendingApproveId ? '继续提交审批' : '提交审批' }}</el-button
  233. >
  234. </span>
  235. <el-dialog
  236. title="批次变更申请详情"
  237. :visible.sync="detailVisible"
  238. width="850px"
  239. append-to-body
  240. >
  241. <el-descriptions
  242. v-loading="detailLoading"
  243. :column="3"
  244. border
  245. size="small"
  246. >
  247. <el-descriptions-item label="审核代码">{{
  248. detailData.changeNo || '-'
  249. }}</el-descriptions-item>
  250. <el-descriptions-item label="变更类型">{{
  251. detailData.changeType === 1 ? '拆批' : '合批'
  252. }}</el-descriptions-item>
  253. <el-descriptions-item label="审批状态">{{
  254. statusText(detailData.verifyStatus)
  255. }}</el-descriptions-item>
  256. <el-descriptions-item label="仓库">{{
  257. detailData.warehouseName || '-'
  258. }}</el-descriptions-item>
  259. <el-descriptions-item label="申请时间">{{
  260. detailData.createTime || '-'
  261. }}</el-descriptions-item>
  262. <el-descriptions-item label="说明">{{
  263. detailData.remark || '-'
  264. }}</el-descriptions-item>
  265. </el-descriptions>
  266. <el-table
  267. :data="detailData.details || []"
  268. size="mini"
  269. border
  270. class="detail-table"
  271. >
  272. <el-table-column prop="sourceStockBatchId" label="来源批次ID" />
  273. <el-table-column
  274. prop="systemBatchNo"
  275. label="来源系统批次"
  276. min-width="140"
  277. />
  278. <el-table-column
  279. prop="targetSystemBatchNo"
  280. label="目标系统批次"
  281. min-width="140"
  282. />
  283. <el-table-column prop="measureQuantity" label="计量数量" />
  284. <el-table-column prop="packageQuantity" label="包装数量" />
  285. <el-table-column prop="weight" label="重量" />
  286. <el-table-column prop="lockQuantity" label="锁库数量" />
  287. <el-table-column
  288. prop="packingStockIds"
  289. label="包装库存分配"
  290. min-width="180"
  291. show-overflow-tooltip
  292. />
  293. <el-table-column
  294. prop="lockAllocations"
  295. label="锁定单分配"
  296. min-width="220"
  297. show-overflow-tooltip
  298. />
  299. </el-table>
  300. </el-dialog>
  301. </el-dialog>
  302. </template>
  303. <script>
  304. import BigNumber from 'bignumber.js';
  305. import stockBatchChange from '@/api/warehouseManagement/stockBatchChange';
  306. function decimal(value) {
  307. const result = new BigNumber(value == null || value === '' ? 0 : value);
  308. return result.isFinite() ? result : new BigNumber(0);
  309. }
  310. function decimalValue(value) {
  311. return decimal(value).decimalPlaces(6).toFixed(6);
  312. }
  313. function isEmptyDecimal(value) {
  314. return value == null || String(value).trim() === '';
  315. }
  316. function decimalEquals(left, right) {
  317. const leftEmpty = isEmptyDecimal(left);
  318. const rightEmpty = isEmptyDecimal(right);
  319. if (leftEmpty || rightEmpty) return leftEmpty && rightEmpty;
  320. const leftDecimal = new BigNumber(left);
  321. const rightDecimal = new BigNumber(right);
  322. if (!leftDecimal.isFinite() || !rightDecimal.isFinite()) {
  323. return String(left) === String(right);
  324. }
  325. return leftDecimal.eq(rightDecimal);
  326. }
  327. const DECIMAL_INPUT_PATTERN = /^\d{1,12}(?:\.\d{1,6})?$/;
  328. export default {
  329. data() {
  330. return {
  331. visible: false,
  332. mode: 'split',
  333. sources: [],
  334. targets: [],
  335. remark: '',
  336. saving: false,
  337. sourceLoading: false,
  338. detailVisible: false,
  339. detailLoading: false,
  340. detailData: {},
  341. pendingApproveId: null,
  342. recordActionId: null
  343. };
  344. },
  345. computed: {
  346. dialogTitle() {
  347. if (this.mode === 'records') return '批次变更申请记录';
  348. return this.mode === 'split' ? '库存拆批申请' : '库存合批申请';
  349. },
  350. packingRows() {
  351. return this.sources.reduce(
  352. (rows, source) => rows.concat(source.packingDetails || []),
  353. []
  354. );
  355. },
  356. lockRows() {
  357. return this.sources.reduce(
  358. (rows, source) => rows.concat(source.lockDetails || []),
  359. []
  360. );
  361. },
  362. hasPacking() {
  363. return this.packingRows.length > 0;
  364. },
  365. targetRows() {
  366. return this.targets.map((target) => {
  367. const packages = this.packingRows.filter(
  368. (row) => row.targetDetailId === target.targetDetailId
  369. );
  370. const locks = this.lockRows.filter(
  371. (row) => row.targetDetailId === target.targetDetailId
  372. );
  373. const packingMeasureQuantity = this.sum(packages, 'measureQuantity');
  374. const packingWeight = this.sum(packages, 'weight');
  375. return {
  376. target,
  377. targetDetailId: target.targetDetailId,
  378. packingMeasureQuantity,
  379. packingWeight,
  380. measureQuantity: target.measureQuantity,
  381. packageQuantity: this.hasPacking
  382. ? this.sum(packages, 'packageQuantity')
  383. : target.packageQuantity,
  384. weight: target.weight,
  385. lockQuantity: this.sum(locks, 'quantity')
  386. };
  387. });
  388. },
  389. recordColumns() {
  390. return [
  391. { prop: 'changeNo', label: '审核代码' },
  392. {
  393. prop: 'changeType',
  394. label: '类型',
  395. formatter: (row) => (row.changeType === 1 ? '拆批' : '合批')
  396. },
  397. { prop: 'verifyStatus', slot: 'status', label: '审批状态' },
  398. { prop: 'createTime', label: '申请时间' },
  399. {
  400. columnKey: 'action',
  401. slot: 'action',
  402. label: '操作',
  403. width: 140,
  404. fixed: 'right'
  405. }
  406. ];
  407. }
  408. },
  409. methods: {
  410. async open(mode, rows) {
  411. this.mode = mode;
  412. this.pendingApproveId = null;
  413. this.visible = true;
  414. if (mode === 'records') return;
  415. this.sourceLoading = true;
  416. try {
  417. const selectedRows = rows || [];
  418. const ids = selectedRows.map((row) => row.stockBatchId || row.id);
  419. const result = await stockBatchChange.sourceDetails(ids);
  420. const sourceList = Array.isArray(result)
  421. ? result
  422. : result.sources || [];
  423. this.sources = sourceList.map((source) => {
  424. const selected =
  425. selectedRows.find(
  426. (row) =>
  427. String(row.stockBatchId || row.id) ===
  428. String(source.stockBatchId)
  429. ) || {};
  430. return this.normalizeSource({ ...selected, ...source });
  431. });
  432. this.targets =
  433. mode === 'merge'
  434. ? [this.createTarget(1)]
  435. : [this.createTarget(1), this.createTarget(2)];
  436. if (mode === 'merge') this.assignMergeTarget();
  437. } catch (error) {
  438. this.visible = false;
  439. this.$message.error(error.message || '批次来源详情加载失败');
  440. } finally {
  441. this.sourceLoading = false;
  442. }
  443. },
  444. normalizeSource(source) {
  445. const packingDetails = (source.packingDetails || []).map((row) => ({
  446. ...row,
  447. sourceStockBatchId: source.stockBatchId,
  448. systemBatchNo: source.systemBatchNo,
  449. targetDetailId: null
  450. }));
  451. const lockDetails = (source.lockDetails || []).map((row) => ({
  452. ...row,
  453. sourceStockBatchId: source.stockBatchId,
  454. lockedPackingStockIds: this.parseIdList(row.lockedPackingStockIds),
  455. targetDetailId: null,
  456. targetConflict: false
  457. }));
  458. return {
  459. ...source,
  460. stockCountBase:
  461. source.stockCountBase == null
  462. ? source.measureQuantity
  463. : source.stockCountBase,
  464. packingQuantity:
  465. source.packingQuantity == null
  466. ? source.packageQuantity
  467. : source.packingQuantity,
  468. packingDetails,
  469. lockDetails
  470. };
  471. },
  472. parseIdList(value) {
  473. if (Array.isArray(value)) return value.map(String);
  474. if (!value) return [];
  475. try {
  476. const parsed = JSON.parse(value);
  477. return Array.isArray(parsed) ? parsed.map(String) : [];
  478. } catch (error) {
  479. return [];
  480. }
  481. },
  482. createTarget(targetDetailId) {
  483. return {
  484. targetDetailId,
  485. measureQuantity: '0',
  486. packageQuantity: '0',
  487. weight: '0'
  488. };
  489. },
  490. reset() {
  491. this.sources = [];
  492. this.targets = [];
  493. this.remark = '';
  494. this.detailVisible = false;
  495. this.detailData = {};
  496. this.pendingApproveId = null;
  497. },
  498. addTarget() {
  499. const maxId = this.targets.reduce(
  500. (max, target) => Math.max(max, target.targetDetailId),
  501. 0
  502. );
  503. this.targets.push(this.createTarget(maxId + 1));
  504. },
  505. removeTarget(targetDetailId) {
  506. if (this.targets.length <= 2)
  507. return this.$message.warning('拆批至少保留两个目标子批次');
  508. if (
  509. this.packingRows.some(
  510. (row) => row.targetDetailId === targetDetailId
  511. ) ||
  512. this.lockRows.some((row) => row.targetDetailId === targetDetailId)
  513. ) {
  514. return this.$message.warning('该目标已有包装或锁定单分配,不能删除');
  515. }
  516. this.targets = this.targets.filter(
  517. (target) => target.targetDetailId !== targetDetailId
  518. );
  519. },
  520. assignMergeTarget() {
  521. this.packingRows.forEach((row) => {
  522. row.targetDetailId = 1;
  523. });
  524. this.lockRows.forEach((row) => {
  525. row.targetDetailId = 1;
  526. });
  527. const target = this.targets[0];
  528. target.measureQuantity = this.sum(this.sources, 'stockCountBase');
  529. target.packageQuantity = this.sum(this.sources, 'packingQuantity');
  530. target.weight = this.sum(this.sources, 'weight');
  531. this.syncTargetPackingMinimums();
  532. },
  533. /** 包装分配变化时,同步其关联锁定单的全部明细和包装。 */
  534. assignPackingTarget(packing, targetDetailId) {
  535. const relatedLocks = this.lockRows.filter((lock) =>
  536. lock.lockedPackingStockIds.some(
  537. (id) => String(id) === String(packing.packingStockId)
  538. )
  539. );
  540. if (relatedLocks.length) {
  541. this.assignConnectedLockRows(relatedLocks, targetDetailId);
  542. }
  543. this.syncTargetPackingMinimums();
  544. },
  545. /** 锁定单必须整单分配,且所有关联包装必须与锁定单落到同一目标。 */
  546. assignLockOrderTarget(lock, targetDetailId) {
  547. this.assignConnectedLockRows([lock], targetDetailId);
  548. this.syncTargetPackingMinimums();
  549. },
  550. /** 传播同锁定单及共享包装关系,确保整个关联组只有一个目标。 */
  551. assignConnectedLockRows(seedLocks, targetDetailId) {
  552. const lockOrderIds = new Set(
  553. seedLocks.map((lock) => String(lock.lockOrderId))
  554. );
  555. const packingIds = new Set();
  556. let changed = true;
  557. while (changed) {
  558. changed = false;
  559. this.lockRows.forEach((lock) => {
  560. const linkedToOrder = lockOrderIds.has(String(lock.lockOrderId));
  561. const linkedToPacking = lock.lockedPackingStockIds.some((id) =>
  562. packingIds.has(String(id))
  563. );
  564. if (!linkedToOrder && !linkedToPacking) return;
  565. if (!lockOrderIds.has(String(lock.lockOrderId))) {
  566. lockOrderIds.add(String(lock.lockOrderId));
  567. changed = true;
  568. }
  569. lock.lockedPackingStockIds.forEach((id) => {
  570. if (!packingIds.has(String(id))) {
  571. packingIds.add(String(id));
  572. changed = true;
  573. }
  574. });
  575. });
  576. }
  577. this.lockRows.forEach((lock) => {
  578. if (!lockOrderIds.has(String(lock.lockOrderId))) return;
  579. lock.targetDetailId = targetDetailId;
  580. lock.targetConflict = false;
  581. });
  582. this.packingRows.forEach((packing) => {
  583. if (packingIds.has(String(packing.packingStockId))) {
  584. packing.targetDetailId = targetDetailId;
  585. }
  586. });
  587. },
  588. /** 包装变更后同步目标下限,超出包装汇总的部分作为散量。 */
  589. syncTargetPackingMinimums() {
  590. this.targets.forEach((target) => {
  591. const packages = this.packingRows.filter(
  592. (row) => row.targetDetailId === target.targetDetailId
  593. );
  594. const measureMinimum = decimal(this.sum(packages, 'measureQuantity'));
  595. const weightMinimum = decimal(this.sum(packages, 'weight'));
  596. if (decimal(target.measureQuantity).lt(measureMinimum)) {
  597. target.measureQuantity = measureMinimum.toFixed(6);
  598. }
  599. if (decimal(target.weight).lt(weightMinimum)) {
  600. target.weight = weightMinimum.toFixed(6);
  601. }
  602. });
  603. },
  604. targetLabel(id) {
  605. return this.mode === 'merge' ? '合并目标' : `子批次 ${id}`;
  606. },
  607. packingNames(lock) {
  608. if (!lock.lockedPackingStockIds.length) return '-';
  609. return lock.lockedPackingStockIds
  610. .map((id) => {
  611. const packing = this.packingRows.find(
  612. (row) => String(row.packingStockId) === String(id)
  613. );
  614. return packing ? packing.packageNo : id;
  615. })
  616. .join('、');
  617. },
  618. sum(rows, key) {
  619. return rows
  620. .reduce((sum, row) => sum.plus(decimal(row[key])), new BigNumber(0))
  621. .toFixed(6);
  622. },
  623. total(key) {
  624. return this.sum(this.sources, key);
  625. },
  626. statusText(value) {
  627. return (
  628. { 0: '未提交', 1: '审批中', 2: '已通过', 3: '已驳回', 4: '已撤回' }[
  629. value
  630. ] || '-'
  631. );
  632. },
  633. statusType(value) {
  634. return value === 2 ? 'success' : value === 3 ? 'danger' : 'info';
  635. },
  636. recordSource({ page, limit, where }) {
  637. return stockBatchChange.page({ ...where, pageNum: page, size: limit });
  638. },
  639. async showDetail(row) {
  640. this.detailVisible = true;
  641. this.detailLoading = true;
  642. this.detailData = {};
  643. try {
  644. this.detailData = (await stockBatchChange.detail(row.id)) || {};
  645. } catch (error) {
  646. this.detailVisible = false;
  647. this.$message.error(error.message || '申请详情加载失败');
  648. } finally {
  649. this.detailLoading = false;
  650. }
  651. },
  652. async submitRecord(row) {
  653. this.recordActionId = row.id;
  654. try {
  655. await stockBatchChange.submit({ id: row.id });
  656. this.$message.success('申请已提交审批');
  657. this.reloadRecords();
  658. } finally {
  659. this.recordActionId = null;
  660. }
  661. },
  662. async withdrawRecord(row) {
  663. try {
  664. await this.$confirm('确认撤回该批次变更申请?', '提示', {
  665. type: 'warning'
  666. });
  667. } catch (error) {
  668. return;
  669. }
  670. this.recordActionId = row.id;
  671. try {
  672. await stockBatchChange.withdraw({ id: row.id });
  673. this.$message.success('申请已撤回');
  674. this.reloadRecords();
  675. } finally {
  676. this.recordActionId = null;
  677. }
  678. },
  679. reloadRecords() {
  680. this.$nextTick(() => {
  681. if (this.$refs.recordTable) this.$refs.recordTable.reload();
  682. });
  683. },
  684. getBatchAttributeDifferences() {
  685. const attributes = [
  686. { key: 'stockId', label: '库存台账ID' },
  687. { key: 'warehouseId', label: '仓库', displayKey: 'warehouseName' },
  688. { key: 'batchNo', label: '业务批次号' },
  689. { key: 'categoryId', label: '物品ID' },
  690. { key: 'categoryCode', label: '物品编码' },
  691. { key: 'categoryName', label: '物品名称' },
  692. { key: 'specification', label: '规格' },
  693. { key: 'categoryModel', label: '型号' },
  694. { key: 'brandNum', label: '牌号' },
  695. { key: 'colorKey', label: '颜色' },
  696. { key: 'unitPrice', label: '单价', decimal: true },
  697. { key: 'supplierId', label: '供应商ID' },
  698. { key: 'supplierName', label: '供应商' },
  699. { key: 'supplierCode', label: '供应商代号' }
  700. ];
  701. return attributes.reduce((differences, attribute) => {
  702. const values = this.sources.map((row) => row[attribute.key]);
  703. const firstValue = values[0];
  704. const hasDifference = attribute.decimal
  705. ? values.some((value) => !decimalEquals(firstValue, value))
  706. : new Set(values.map((value) => String(value == null ? '' : value)))
  707. .size > 1;
  708. if (!hasDifference) return differences;
  709. const displayValues = Array.from(
  710. new Set(
  711. this.sources.map((row) => {
  712. const displayValue = attribute.displayKey
  713. ? row[attribute.displayKey]
  714. : null;
  715. const value =
  716. displayValue == null || displayValue === ''
  717. ? row[attribute.key]
  718. : displayValue;
  719. return value == null || value === '' ? '未填写' : value;
  720. })
  721. )
  722. );
  723. differences.push(`${attribute.label}(${displayValues.join('、')})`);
  724. return differences;
  725. }, []);
  726. },
  727. validateAllocation() {
  728. if (this.mode === 'split' && this.targets.length < 2)
  729. return '拆批至少需要两个目标子批次';
  730. if (
  731. this.mode === 'split' &&
  732. this.targets.some((target) =>
  733. ['measureQuantity', 'packageQuantity', 'weight'].some(
  734. (key) => !DECIMAL_INPUT_PATTERN.test(String(target[key]).trim())
  735. )
  736. )
  737. )
  738. return '计量数量、包装数量和重量最多支持12位整数、6位小数';
  739. if (this.packingRows.some((row) => !row.targetDetailId))
  740. return '请为每条包装明细选择目标子批次';
  741. if (
  742. this.lockRows.some((row) => row.targetConflict || !row.targetDetailId)
  743. )
  744. return '请完成锁定单分配,关联包装必须属于同一目标';
  745. const lockOrderTargets = new Map();
  746. for (const lock of this.lockRows) {
  747. const lockOrderId = String(lock.lockOrderId);
  748. const assignedTarget = lockOrderTargets.get(lockOrderId);
  749. if (
  750. lockOrderTargets.has(lockOrderId) &&
  751. assignedTarget !== lock.targetDetailId
  752. ) {
  753. return '同一锁定单的全部明细必须分配到同一目标子批次';
  754. }
  755. lockOrderTargets.set(lockOrderId, lock.targetDetailId);
  756. const packingTargetMismatch = lock.lockedPackingStockIds.some(
  757. (id) => {
  758. const packing = this.packingRows.find(
  759. (row) => String(row.packingStockId) === String(id)
  760. );
  761. return !packing || packing.targetDetailId !== lock.targetDetailId;
  762. }
  763. );
  764. if (packingTargetMismatch)
  765. return '锁定单关联包装必须分配到同一目标子批次';
  766. }
  767. if (this.targetRows.some((row) => decimal(row.measureQuantity).lte(0)))
  768. return '各目标子批次计量数量必须大于零';
  769. if (
  770. this.targetRows.some((row) =>
  771. decimal(row.lockQuantity).gt(decimal(row.measureQuantity))
  772. )
  773. )
  774. return '目标子批次锁库数量不能大于计量数量';
  775. if (
  776. this.hasPacking &&
  777. this.targetRows.some(
  778. (row) =>
  779. decimal(row.measureQuantity).lt(
  780. decimal(row.packingMeasureQuantity)
  781. ) || decimal(row.weight).lt(decimal(row.packingWeight))
  782. )
  783. )
  784. return '目标计量数量和重量不能小于所选整包装汇总';
  785. const source = this.sources[0];
  786. if (this.mode === 'split') {
  787. if (
  788. !decimal(this.sum(this.targetRows, 'measureQuantity')).eq(
  789. decimal(source.stockCountBase)
  790. ) ||
  791. !decimal(this.sum(this.targetRows, 'packageQuantity')).eq(
  792. decimal(source.packingQuantity)
  793. ) ||
  794. !decimal(this.sum(this.targetRows, 'weight')).eq(
  795. decimal(source.weight)
  796. )
  797. ) {
  798. return '目标计量数量、包装数量和重量合计必须等于来源库存';
  799. }
  800. }
  801. return '';
  802. },
  803. buildLockAllocations(sourceStockBatchId, targetDetailId) {
  804. return JSON.stringify(
  805. this.lockRows
  806. .filter(
  807. (row) =>
  808. row.sourceStockBatchId === sourceStockBatchId &&
  809. row.targetDetailId === targetDetailId
  810. )
  811. .map((row) => ({
  812. lockOrderDetailId: row.lockOrderDetailId,
  813. quantity: decimalValue(row.quantity)
  814. }))
  815. );
  816. },
  817. buildPackingIds(sourceStockBatchId, targetDetailId) {
  818. return JSON.stringify(
  819. this.packingRows
  820. .filter(
  821. (row) =>
  822. row.sourceStockBatchId === sourceStockBatchId &&
  823. row.targetDetailId === targetDetailId
  824. )
  825. .map((row) => row.packingStockId)
  826. );
  827. },
  828. buildSplitDetails() {
  829. const source = this.sources[0];
  830. return this.targetRows.map((row) => ({
  831. detailType: 2,
  832. sourceStockBatchId: source.stockBatchId,
  833. targetDetailId: row.targetDetailId,
  834. batchNo: source.batchNo,
  835. systemBatchNo: source.systemBatchNo,
  836. measureQuantity: decimalValue(row.measureQuantity),
  837. packageQuantity: decimalValue(row.packageQuantity),
  838. weight: decimalValue(row.weight),
  839. lockQuantity: decimalValue(row.lockQuantity),
  840. packingStockIds: this.buildPackingIds(
  841. source.stockBatchId,
  842. row.targetDetailId
  843. ),
  844. lockAllocations: this.buildLockAllocations(
  845. source.stockBatchId,
  846. row.targetDetailId
  847. )
  848. }));
  849. },
  850. buildMergeDetails() {
  851. return this.sources.map((source) => ({
  852. detailType: 1,
  853. sourceStockBatchId: source.stockBatchId,
  854. targetDetailId: 1,
  855. batchNo: source.batchNo,
  856. systemBatchNo: source.systemBatchNo,
  857. measureQuantity: decimalValue(source.stockCountBase),
  858. packageQuantity: decimalValue(source.packingQuantity),
  859. weight: decimalValue(source.weight),
  860. lockQuantity: this.sum(source.lockDetails || [], 'quantity'),
  861. packingStockIds: this.buildPackingIds(source.stockBatchId, 1),
  862. lockAllocations: this.buildLockAllocations(source.stockBatchId, 1)
  863. }));
  864. },
  865. async submitApply() {
  866. if (this.pendingApproveId) return this.continueSubmit();
  867. if (!this.sources.length)
  868. return this.$message.warning('请选择库存批次');
  869. if (this.mode === 'merge' && this.sources.length < 2)
  870. return this.$message.warning('合批至少选择两条库存');
  871. const attributeDifferences = this.getBatchAttributeDifferences();
  872. if (this.mode === 'merge' && attributeDifferences.length)
  873. return this.$message.warning(
  874. `所选库存无法合批,以下信息不一致:${attributeDifferences.join(
  875. ';'
  876. )}。`
  877. );
  878. const allocationError = this.validateAllocation();
  879. if (allocationError) return this.$message.warning(allocationError);
  880. this.saving = true;
  881. try {
  882. const source = this.sources[0];
  883. const saved = await stockBatchChange.save({
  884. changeType: this.mode === 'split' ? 1 : 2,
  885. warehouseId: source.warehouseId,
  886. warehouseName: source.warehouseName,
  887. remark: this.remark,
  888. details:
  889. this.mode === 'split'
  890. ? this.buildSplitDetails()
  891. : this.buildMergeDetails()
  892. });
  893. this.pendingApproveId = saved.id || saved;
  894. await stockBatchChange.submit({ id: this.pendingApproveId });
  895. this.pendingApproveId = null;
  896. this.$message.success('申请已提交,审批通过后生效');
  897. this.visible = false;
  898. this.$emit('success');
  899. } finally {
  900. this.saving = false;
  901. }
  902. },
  903. async continueSubmit() {
  904. this.saving = true;
  905. try {
  906. await stockBatchChange.submit({ id: this.pendingApproveId });
  907. this.pendingApproveId = null;
  908. this.$message.success('申请已提交,审批通过后生效');
  909. this.visible = false;
  910. this.$emit('success');
  911. } finally {
  912. this.saving = false;
  913. }
  914. }
  915. }
  916. };
  917. </script>
  918. <style scoped>
  919. .section-title {
  920. margin: 14px 0 8px;
  921. font-weight: 600;
  922. }
  923. .merge-summary {
  924. padding: 14px 0 0;
  925. color: #606266;
  926. }
  927. .remark {
  928. margin-top: 14px;
  929. }
  930. .saved-alert {
  931. margin-top: 10px;
  932. }
  933. .error-text {
  934. margin-top: 4px;
  935. color: #f56c6c;
  936. font-size: 12px;
  937. }
  938. .detail-table {
  939. margin-top: 14px;
  940. }
  941. </style>