BatchChangeDialog.vue 30 KB

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