Przeglądaj źródła

fix:拆合批-1

mlchen 1 dzień temu
rodzic
commit
615d04ab45

+ 28 - 0
src/api/warehouseManagement/stockBatchChange.js

@@ -0,0 +1,28 @@
+import request from '@/utils/request';
+
+const base = '/wms/stockBatchChangeApprove';
+const flowBase = '/bpm/stockBatchChangeApprove';
+
+function unwrap(res) {
+  if (res.data.code === 0 || res.data.code === '0')
+    return res.data.data || res.data;
+  return Promise.reject(new Error(res.data.message || '操作失败'));
+}
+
+export default {
+  page: (params) => request.get(`${base}/page`, { params }).then(unwrap),
+  detail: (id) => request.get(`${base}/getById/${id}`).then(unwrap),
+  sourceDetails: (stockBatchIds) =>
+    request.post(`${base}/sourceDetails`, { stockBatchIds }).then(unwrap),
+  save: (data) => request.post(`${base}/save`, data).then(unwrap),
+  submit: (data) =>
+    request
+      .post(`${flowBase}/submit`, {
+        businessId: String(data.id || data.businessId)
+      })
+      .then(unwrap),
+  withdraw: (data) =>
+    request
+      .post(`${flowBase}/withdraw`, { businessId: String(data.id) })
+      .then(unwrap)
+};

+ 870 - 0
src/views/warehouseManagement/stockLedger/components/BatchChangeDialog.vue

@@ -0,0 +1,870 @@
+<template>
+  <el-dialog
+    :title="dialogTitle"
+    :visible.sync="visible"
+    width="1050px"
+    :close-on-click-modal="false"
+    @close="reset"
+  >
+    <template v-if="mode === 'records'">
+      <ele-pro-table
+        ref="recordTable"
+        :columns="recordColumns"
+        :datasource="recordSource"
+        height="450px"
+        :pageSize="10"
+      >
+        <template v-slot:status="{ row }">
+          <el-tag :type="statusType(row.verifyStatus)">
+            {{ statusText(row.verifyStatus) }}
+          </el-tag>
+        </template>
+        <template v-slot:action="{ row }">
+          <el-button type="text" @click="showDetail(row)">详情</el-button>
+          <el-button
+            v-if="row.verifyStatus === 0"
+            type="text"
+            :loading="recordActionId === row.id"
+            @click="submitRecord(row)"
+            >提交</el-button
+          >
+          <el-button
+            v-if="row.verifyStatus === 1"
+            type="text"
+            :loading="recordActionId === row.id"
+            @click="withdrawRecord(row)"
+            >撤回</el-button
+          >
+        </template>
+      </ele-pro-table>
+    </template>
+
+    <div v-else v-loading="sourceLoading">
+      <el-alert
+        v-if="mode === 'merge'"
+        title="仅允许同仓库、批次号、物品属性、供应商和价格完全一致的库存合批,审批通过后生成新系统批次。"
+        type="info"
+        :closable="false"
+      />
+      <el-alert
+        v-else
+        title="包装明细必须整行分配;锁定明细按锁定单分配,关联包装时自动跟随包装目标。"
+        type="info"
+        :closable="false"
+      />
+      <el-alert
+        v-if="pendingApproveId"
+        title="申请已保存,当前仅续提审批,不会重复生成审核代码。"
+        type="warning"
+        :closable="false"
+        class="saved-alert"
+      />
+
+      <div class="section-title">来源库存</div>
+      <el-table :data="sources" size="mini" border>
+        <el-table-column
+          prop="systemBatchNo"
+          label="来源系统批次"
+          min-width="150"
+        />
+        <el-table-column prop="batchNo" label="业务批次" />
+        <el-table-column prop="warehouseName" label="仓库" />
+        <el-table-column prop="categoryCode" label="物品编码" />
+        <el-table-column prop="stockCountBase" label="计量数量" />
+        <el-table-column prop="packingQuantity" label="包装数量" />
+        <el-table-column prop="weight" label="重量" />
+      </el-table>
+
+      <template v-if="mode === 'split'">
+        <div class="section-title">目标子批次</div>
+        <el-table :data="targetRows" size="mini" border>
+          <el-table-column label="目标" width="90">
+            <template slot-scope="{ row }"
+              >子批次 {{ row.targetDetailId }}</template
+            >
+          </el-table-column>
+          <el-table-column label="系统批次号" width="130">
+            <template>审批后生成</template>
+          </el-table-column>
+          <el-table-column label="计量数量">
+            <template slot-scope="{ row }">
+              <el-input-number
+                v-model="row.target.measureQuantity"
+                :min="hasPacking ? Number(row.packingMeasureQuantity) : 0"
+                :precision="6"
+                :step="0.000001"
+                :disabled="Boolean(pendingApproveId)"
+                size="mini"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="包装数量">
+            <template slot-scope="{ row }">
+              <span v-if="hasPacking">{{ row.packageQuantity }}</span>
+              <el-input-number
+                v-else
+                v-model="row.target.packageQuantity"
+                :min="0"
+                :precision="6"
+                :step="0.000001"
+                :disabled="Boolean(pendingApproveId)"
+                size="mini"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="重量">
+            <template slot-scope="{ row }">
+              <el-input-number
+                v-model="row.target.weight"
+                :min="hasPacking ? Number(row.packingWeight) : 0"
+                :precision="6"
+                :step="0.000001"
+                :disabled="Boolean(pendingApproveId)"
+                size="mini"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column prop="lockQuantity" label="锁库数量" />
+          <el-table-column width="70">
+            <template slot-scope="{ row }">
+              <el-button
+                type="text"
+                :disabled="Boolean(pendingApproveId)"
+                @click="removeTarget(row.targetDetailId)"
+                >删除</el-button
+              >
+            </template>
+          </el-table-column>
+        </el-table>
+        <el-button
+          type="text"
+          icon="el-icon-plus"
+          :disabled="Boolean(pendingApproveId)"
+          @click="addTarget"
+          >新增子批次</el-button
+        >
+      </template>
+      <div v-else class="merge-summary">
+        合并后计量数量:{{ total('stockCountBase') }},包装数量:{{
+          total('packingQuantity')
+        }},重量:{{ total('weight') }};存货周期取来源最长值。
+      </div>
+
+      <template v-if="packingRows.length">
+        <div class="section-title">整包装分配</div>
+        <el-table :data="packingRows" size="mini" border max-height="260">
+          <el-table-column
+            prop="systemBatchNo"
+            label="来源系统批次"
+            min-width="140"
+          />
+          <el-table-column prop="packageNo" label="包装编码" min-width="130" />
+          <el-table-column prop="measureQuantity" label="计量数量" />
+          <el-table-column prop="packageQuantity" label="包装数量" />
+          <el-table-column prop="weight" label="重量" />
+          <el-table-column prop="lockCountBase" label="锁库数量" />
+          <el-table-column label="目标子批次" min-width="150">
+            <template slot-scope="{ row }">
+              <el-select
+                v-model="row.targetDetailId"
+                size="mini"
+                :disabled="mode === 'merge' || Boolean(pendingApproveId)"
+                placeholder="请选择"
+                @change="syncLockTargets"
+              >
+                <el-option
+                  v-for="target in targets"
+                  :key="target.targetDetailId"
+                  :label="targetLabel(target.targetDetailId)"
+                  :value="target.targetDetailId"
+                />
+              </el-select>
+            </template>
+          </el-table-column>
+        </el-table>
+      </template>
+
+      <template v-if="lockRows.length">
+        <div class="section-title">锁定单分配</div>
+        <el-table :data="lockRows" size="mini" border max-height="260">
+          <el-table-column prop="orderNo" label="锁定单号" min-width="140" />
+          <el-table-column
+            prop="lockOrderDetailId"
+            label="锁定明细ID"
+            min-width="110"
+          />
+          <el-table-column prop="quantity" label="锁定数量" />
+          <el-table-column label="关联包装" min-width="180">
+            <template slot-scope="{ row }">{{ packingNames(row) }}</template>
+          </el-table-column>
+          <el-table-column label="目标子批次" min-width="150">
+            <template slot-scope="{ row }">
+              <el-select
+                v-model="row.targetDetailId"
+                size="mini"
+                :disabled="
+                  mode === 'merge' ||
+                  row.lockedPackingStockIds.length > 0 ||
+                  Boolean(pendingApproveId)
+                "
+                placeholder="请选择"
+              >
+                <el-option
+                  v-for="target in targets"
+                  :key="target.targetDetailId"
+                  :label="targetLabel(target.targetDetailId)"
+                  :value="target.targetDetailId"
+                />
+              </el-select>
+              <div v-if="row.targetConflict" class="error-text"
+                >关联包装不能跨目标</div
+              >
+            </template>
+          </el-table-column>
+        </el-table>
+      </template>
+
+      <el-input
+        v-model="remark"
+        type="textarea"
+        :rows="2"
+        :disabled="Boolean(pendingApproveId)"
+        placeholder="申请说明(可选)"
+        class="remark"
+      />
+    </div>
+
+    <span slot="footer">
+      <el-button @click="visible = false">取 消</el-button>
+      <el-button
+        v-if="mode !== 'records'"
+        type="primary"
+        :loading="saving"
+        :disabled="sourceLoading"
+        @click="submitApply"
+        >{{ pendingApproveId ? '继续提交审批' : '提交审批' }}</el-button
+      >
+    </span>
+
+    <el-dialog
+      title="批次变更申请详情"
+      :visible.sync="detailVisible"
+      width="850px"
+      append-to-body
+    >
+      <el-descriptions
+        v-loading="detailLoading"
+        :column="3"
+        border
+        size="small"
+      >
+        <el-descriptions-item label="审核代码">{{
+          detailData.changeNo || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="变更类型">{{
+          detailData.changeType === 1 ? '拆批' : '合批'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="审批状态">{{
+          statusText(detailData.verifyStatus)
+        }}</el-descriptions-item>
+        <el-descriptions-item label="仓库">{{
+          detailData.warehouseName || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="申请时间">{{
+          detailData.createTime || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="说明">{{
+          detailData.remark || '-'
+        }}</el-descriptions-item>
+      </el-descriptions>
+      <el-table
+        :data="detailData.details || []"
+        size="mini"
+        border
+        class="detail-table"
+      >
+        <el-table-column prop="sourceStockBatchId" label="来源批次ID" />
+        <el-table-column
+          prop="systemBatchNo"
+          label="来源系统批次"
+          min-width="140"
+        />
+        <el-table-column
+          prop="targetSystemBatchNo"
+          label="目标系统批次"
+          min-width="140"
+        />
+        <el-table-column prop="measureQuantity" label="计量数量" />
+        <el-table-column prop="packageQuantity" label="包装数量" />
+        <el-table-column prop="weight" label="重量" />
+        <el-table-column prop="lockQuantity" label="锁库数量" />
+        <el-table-column
+          prop="packingStockIds"
+          label="包装库存分配"
+          min-width="180"
+          show-overflow-tooltip
+        />
+        <el-table-column
+          prop="lockAllocations"
+          label="锁定单分配"
+          min-width="220"
+          show-overflow-tooltip
+        />
+      </el-table>
+    </el-dialog>
+  </el-dialog>
+</template>
+
+<script>
+  import BigNumber from 'bignumber.js';
+  import stockBatchChange from '@/api/warehouseManagement/stockBatchChange';
+
+  function decimal(value) {
+    const result = new BigNumber(value == null || value === '' ? 0 : value);
+    return result.isFinite() ? result : new BigNumber(0);
+  }
+
+  function decimalValue(value) {
+    return decimal(value).decimalPlaces(6).toFixed(6);
+  }
+
+  export default {
+    data() {
+      return {
+        visible: false,
+        mode: 'split',
+        sources: [],
+        targets: [],
+        remark: '',
+        saving: false,
+        sourceLoading: false,
+        detailVisible: false,
+        detailLoading: false,
+        detailData: {},
+        pendingApproveId: null,
+        recordActionId: null
+      };
+    },
+    computed: {
+      dialogTitle() {
+        if (this.mode === 'records') return '批次变更申请记录';
+        return this.mode === 'split' ? '库存拆批申请' : '库存合批申请';
+      },
+      packingRows() {
+        return this.sources.reduce(
+          (rows, source) => rows.concat(source.packingDetails || []),
+          []
+        );
+      },
+      lockRows() {
+        return this.sources.reduce(
+          (rows, source) => rows.concat(source.lockDetails || []),
+          []
+        );
+      },
+      hasPacking() {
+        return this.packingRows.length > 0;
+      },
+      targetRows() {
+        return this.targets.map((target) => {
+          const packages = this.packingRows.filter(
+            (row) => row.targetDetailId === target.targetDetailId
+          );
+          const locks = this.lockRows.filter(
+            (row) => row.targetDetailId === target.targetDetailId
+          );
+          const packingMeasureQuantity = this.sum(packages, 'measureQuantity');
+          const packingWeight = this.sum(packages, 'weight');
+          return {
+            target,
+            targetDetailId: target.targetDetailId,
+            packingMeasureQuantity,
+            packingWeight,
+            measureQuantity: target.measureQuantity,
+            packageQuantity: this.hasPacking
+              ? this.sum(packages, 'packageQuantity')
+              : target.packageQuantity,
+            weight: target.weight,
+            lockQuantity: this.sum(locks, 'quantity')
+          };
+        });
+      },
+      recordColumns() {
+        return [
+          { prop: 'changeNo', label: '审核代码' },
+          {
+            prop: 'changeType',
+            label: '类型',
+            formatter: (row) => (row.changeType === 1 ? '拆批' : '合批')
+          },
+          { prop: 'verifyStatus', slot: 'status', label: '审批状态' },
+          { prop: 'createTime', label: '申请时间' },
+          {
+            columnKey: 'action',
+            slot: 'action',
+            label: '操作',
+            width: 140,
+            fixed: 'right'
+          }
+        ];
+      }
+    },
+    methods: {
+      async open(mode, rows) {
+        this.mode = mode;
+        this.pendingApproveId = null;
+        this.visible = true;
+        if (mode === 'records') return;
+        this.sourceLoading = true;
+        try {
+          const selectedRows = rows || [];
+          const ids = selectedRows.map((row) => row.stockBatchId || row.id);
+          const result = await stockBatchChange.sourceDetails(ids);
+          const sourceList = Array.isArray(result)
+            ? result
+            : result.sources || [];
+          this.sources = sourceList.map((source) => {
+            const selected =
+              selectedRows.find(
+                (row) =>
+                  String(row.stockBatchId || row.id) ===
+                  String(source.stockBatchId)
+              ) || {};
+            return this.normalizeSource({ ...selected, ...source });
+          });
+          this.targets =
+            mode === 'merge'
+              ? [this.createTarget(1)]
+              : [this.createTarget(1), this.createTarget(2)];
+          if (mode === 'merge') this.assignMergeTarget();
+        } catch (error) {
+          this.visible = false;
+          this.$message.error(error.message || '批次来源详情加载失败');
+        } finally {
+          this.sourceLoading = false;
+        }
+      },
+      normalizeSource(source) {
+        const packingDetails = (source.packingDetails || []).map((row) => ({
+          ...row,
+          sourceStockBatchId: source.stockBatchId,
+          systemBatchNo: source.systemBatchNo,
+          targetDetailId: null
+        }));
+        const lockDetails = (source.lockDetails || []).map((row) => ({
+          ...row,
+          sourceStockBatchId: source.stockBatchId,
+          lockedPackingStockIds: this.parseIdList(row.lockedPackingStockIds),
+          targetDetailId: null,
+          targetConflict: false
+        }));
+        return {
+          ...source,
+          stockCountBase:
+            source.stockCountBase == null
+              ? source.measureQuantity
+              : source.stockCountBase,
+          packingQuantity:
+            source.packingQuantity == null
+              ? source.packageQuantity
+              : source.packingQuantity,
+          packingDetails,
+          lockDetails
+        };
+      },
+      parseIdList(value) {
+        if (Array.isArray(value)) return value.map(String);
+        if (!value) return [];
+        try {
+          const parsed = JSON.parse(value);
+          return Array.isArray(parsed) ? parsed.map(String) : [];
+        } catch (error) {
+          return [];
+        }
+      },
+      createTarget(targetDetailId) {
+        return {
+          targetDetailId,
+          measureQuantity: 0,
+          packageQuantity: 0,
+          weight: 0
+        };
+      },
+      reset() {
+        this.sources = [];
+        this.targets = [];
+        this.remark = '';
+        this.detailVisible = false;
+        this.detailData = {};
+        this.pendingApproveId = null;
+      },
+      addTarget() {
+        const maxId = this.targets.reduce(
+          (max, target) => Math.max(max, target.targetDetailId),
+          0
+        );
+        this.targets.push(this.createTarget(maxId + 1));
+      },
+      removeTarget(targetDetailId) {
+        if (this.targets.length <= 2)
+          return this.$message.warning('拆批至少保留两个目标子批次');
+        if (
+          this.packingRows.some(
+            (row) => row.targetDetailId === targetDetailId
+          ) ||
+          this.lockRows.some((row) => row.targetDetailId === targetDetailId)
+        ) {
+          return this.$message.warning('该目标已有包装或锁定单分配,不能删除');
+        }
+        this.targets = this.targets.filter(
+          (target) => target.targetDetailId !== targetDetailId
+        );
+      },
+      assignMergeTarget() {
+        this.packingRows.forEach((row) => {
+          row.targetDetailId = 1;
+        });
+        this.lockRows.forEach((row) => {
+          row.targetDetailId = 1;
+        });
+        this.syncTargetPackingMinimums();
+      },
+      /** 包装目标变化后,关联包装的锁定明细必须自动落到同一目标。 */
+      syncLockTargets() {
+        const targetByPackingId = this.packingRows.reduce((map, row) => {
+          map[String(row.packingStockId)] = row.targetDetailId;
+          return map;
+        }, {});
+        this.lockRows.forEach((lock) => {
+          if (!lock.lockedPackingStockIds.length) return;
+          const targetIds = lock.lockedPackingStockIds
+            .map((id) => targetByPackingId[String(id)])
+            .filter(Boolean);
+          const distinct = Array.from(new Set(targetIds));
+          lock.targetConflict = distinct.length > 1;
+          lock.targetDetailId =
+            !lock.targetConflict &&
+            targetIds.length === lock.lockedPackingStockIds.length
+              ? distinct[0]
+              : null;
+        });
+        this.syncTargetPackingMinimums();
+      },
+      /** 包装变更后同步目标下限,超出包装汇总的部分作为散量。 */
+      syncTargetPackingMinimums() {
+        this.targets.forEach((target) => {
+          const packages = this.packingRows.filter(
+            (row) => row.targetDetailId === target.targetDetailId
+          );
+          const measureMinimum = decimal(this.sum(packages, 'measureQuantity'));
+          const weightMinimum = decimal(this.sum(packages, 'weight'));
+          if (decimal(target.measureQuantity).lt(measureMinimum)) {
+            target.measureQuantity = Number(measureMinimum.toFixed(6));
+          }
+          if (decimal(target.weight).lt(weightMinimum)) {
+            target.weight = Number(weightMinimum.toFixed(6));
+          }
+        });
+      },
+      targetLabel(id) {
+        return this.mode === 'merge' ? '合并目标' : `子批次 ${id}`;
+      },
+      packingNames(lock) {
+        if (!lock.lockedPackingStockIds.length) return '-';
+        return lock.lockedPackingStockIds
+          .map((id) => {
+            const packing = this.packingRows.find(
+              (row) => String(row.packingStockId) === String(id)
+            );
+            return packing ? packing.packageNo : id;
+          })
+          .join('、');
+      },
+      sum(rows, key) {
+        return rows
+          .reduce((sum, row) => sum.plus(decimal(row[key])), new BigNumber(0))
+          .toFixed(6);
+      },
+      total(key) {
+        return this.sum(this.sources, key);
+      },
+      statusText(value) {
+        return (
+          { 0: '未提交', 1: '审批中', 2: '已通过', 3: '已驳回', 4: '已撤回' }[
+            value
+          ] || '-'
+        );
+      },
+      statusType(value) {
+        return value === 2 ? 'success' : value === 3 ? 'danger' : 'info';
+      },
+      recordSource({ page, limit, where }) {
+        return stockBatchChange.page({ ...where, pageNum: page, size: limit });
+      },
+      async showDetail(row) {
+        this.detailVisible = true;
+        this.detailLoading = true;
+        this.detailData = {};
+        try {
+          this.detailData = (await stockBatchChange.detail(row.id)) || {};
+        } catch (error) {
+          this.detailVisible = false;
+          this.$message.error(error.message || '申请详情加载失败');
+        } finally {
+          this.detailLoading = false;
+        }
+      },
+      async submitRecord(row) {
+        this.recordActionId = row.id;
+        try {
+          await stockBatchChange.submit({ id: row.id });
+          this.$message.success('申请已提交审批');
+          this.reloadRecords();
+        } finally {
+          this.recordActionId = null;
+        }
+      },
+      async withdrawRecord(row) {
+        try {
+          await this.$confirm('确认撤回该批次变更申请?', '提示', {
+            type: 'warning'
+          });
+        } catch (error) {
+          return;
+        }
+        this.recordActionId = row.id;
+        try {
+          await stockBatchChange.withdraw({ id: row.id });
+          this.$message.success('申请已撤回');
+          this.reloadRecords();
+        } finally {
+          this.recordActionId = null;
+        }
+      },
+      reloadRecords() {
+        this.$nextTick(() => {
+          if (this.$refs.recordTable) this.$refs.recordTable.reload();
+        });
+      },
+      getBatchAttributeDifferences() {
+        const attributes = [
+          { key: 'warehouseId', label: '仓库', displayKey: 'warehouseName' },
+          { key: 'batchNo', label: '业务批次号' },
+          { key: 'categoryCode', label: '物品编码' },
+          { key: 'categoryName', label: '物品名称' },
+          { key: 'specification', label: '规格' },
+          { key: 'categoryModel', label: '型号' },
+          { key: 'brandNum', label: '牌号' },
+          { key: 'colorKey', label: '颜色' },
+          { key: 'modelKey', label: '机型' },
+          { key: 'unitPrice', label: '单价' },
+          { key: 'supplierId', label: '供应商', displayKey: 'supplierName' }
+        ];
+        return attributes.reduce((differences, attribute) => {
+          const values = this.sources.map((row) =>
+            String(row[attribute.key] == null ? '' : row[attribute.key])
+          );
+          if (new Set(values).size < 2) return differences;
+          const displayValues = Array.from(
+            new Set(
+              this.sources.map((row) => {
+                const displayValue = attribute.displayKey
+                  ? row[attribute.displayKey]
+                  : null;
+                const value =
+                  displayValue == null || displayValue === ''
+                    ? row[attribute.key]
+                    : displayValue;
+                return value == null || value === '' ? '未填写' : value;
+              })
+            )
+          );
+          differences.push(`${attribute.label}(${displayValues.join('、')})`);
+          return differences;
+        }, []);
+      },
+      validateAllocation() {
+        if (this.mode === 'split' && this.targets.length < 2)
+          return '拆批至少需要两个目标子批次';
+        if (this.packingRows.some((row) => !row.targetDetailId))
+          return '请为每条包装明细选择目标子批次';
+        if (
+          this.lockRows.some((row) => row.targetConflict || !row.targetDetailId)
+        )
+          return '请完成锁定单分配,关联包装必须属于同一目标';
+        if (this.targetRows.some((row) => decimal(row.measureQuantity).lte(0)))
+          return '各目标子批次计量数量必须大于零';
+        if (
+          this.targetRows.some((row) =>
+            decimal(row.lockQuantity).gt(decimal(row.measureQuantity))
+          )
+        )
+          return '目标子批次锁库数量不能大于计量数量';
+        if (
+          this.hasPacking &&
+          this.targetRows.some(
+            (row) =>
+              decimal(row.measureQuantity).lt(
+                decimal(row.packingMeasureQuantity)
+              ) || decimal(row.weight).lt(decimal(row.packingWeight))
+          )
+        )
+          return '目标计量数量和重量不能小于所选整包装汇总';
+        const source = this.sources[0];
+        if (this.mode === 'split') {
+          if (
+            !decimal(this.sum(this.targetRows, 'measureQuantity')).eq(
+              decimal(source.stockCountBase)
+            ) ||
+            !decimal(this.sum(this.targetRows, 'packageQuantity')).eq(
+              decimal(source.packingQuantity)
+            ) ||
+            !decimal(this.sum(this.targetRows, 'weight')).eq(
+              decimal(source.weight)
+            )
+          ) {
+            return '目标计量数量、包装数量和重量合计必须等于来源库存';
+          }
+        }
+        return '';
+      },
+      buildLockAllocations(sourceStockBatchId, targetDetailId) {
+        return JSON.stringify(
+          this.lockRows
+            .filter(
+              (row) =>
+                row.sourceStockBatchId === sourceStockBatchId &&
+                row.targetDetailId === targetDetailId
+            )
+            .map((row) => ({
+              lockOrderDetailId: row.lockOrderDetailId,
+              quantity: decimalValue(row.quantity)
+            }))
+        );
+      },
+      buildPackingIds(sourceStockBatchId, targetDetailId) {
+        return JSON.stringify(
+          this.packingRows
+            .filter(
+              (row) =>
+                row.sourceStockBatchId === sourceStockBatchId &&
+                row.targetDetailId === targetDetailId
+            )
+            .map((row) => row.packingStockId)
+        );
+      },
+      buildSplitDetails() {
+        const source = this.sources[0];
+        return this.targetRows.map((row) => ({
+          detailType: 2,
+          sourceStockBatchId: source.stockBatchId,
+          targetDetailId: row.targetDetailId,
+          batchNo: source.batchNo,
+          systemBatchNo: source.systemBatchNo,
+          measureQuantity: decimalValue(row.measureQuantity),
+          packageQuantity: decimalValue(row.packageQuantity),
+          weight: decimalValue(row.weight),
+          lockQuantity: decimalValue(row.lockQuantity),
+          packingStockIds: this.buildPackingIds(
+            source.stockBatchId,
+            row.targetDetailId
+          ),
+          lockAllocations: this.buildLockAllocations(
+            source.stockBatchId,
+            row.targetDetailId
+          )
+        }));
+      },
+      buildMergeDetails() {
+        return this.sources.map((source) => ({
+          detailType: 1,
+          sourceStockBatchId: source.stockBatchId,
+          targetDetailId: 1,
+          batchNo: source.batchNo,
+          systemBatchNo: source.systemBatchNo,
+          measureQuantity: decimalValue(source.stockCountBase),
+          packageQuantity: decimalValue(source.packingQuantity),
+          weight: decimalValue(source.weight),
+          lockQuantity: this.sum(source.lockDetails || [], 'quantity'),
+          packingStockIds: this.buildPackingIds(source.stockBatchId, 1),
+          lockAllocations: this.buildLockAllocations(source.stockBatchId, 1)
+        }));
+      },
+      async submitApply() {
+        if (this.pendingApproveId) return this.continueSubmit();
+        if (!this.sources.length)
+          return this.$message.warning('请选择库存批次');
+        if (this.mode === 'merge' && this.sources.length < 2)
+          return this.$message.warning('合批至少选择两条库存');
+        const attributeDifferences = this.getBatchAttributeDifferences();
+        if (this.mode === 'merge' && attributeDifferences.length)
+          return this.$message.warning(
+            `所选库存无法合批,以下信息不一致:${attributeDifferences.join(
+              ';'
+            )}。`
+          );
+        const allocationError = this.validateAllocation();
+        if (allocationError) return this.$message.warning(allocationError);
+        this.saving = true;
+        try {
+          const source = this.sources[0];
+          const saved = await stockBatchChange.save({
+            changeType: this.mode === 'split' ? 1 : 2,
+            warehouseId: source.warehouseId,
+            warehouseName: source.warehouseName,
+            remark: this.remark,
+            details:
+              this.mode === 'split'
+                ? this.buildSplitDetails()
+                : this.buildMergeDetails()
+          });
+          this.pendingApproveId = saved.id || saved;
+          await stockBatchChange.submit({ id: this.pendingApproveId });
+          this.pendingApproveId = null;
+          this.$message.success('申请已提交,审批通过后生效');
+          this.visible = false;
+          this.$emit('success');
+        } finally {
+          this.saving = false;
+        }
+      },
+      async continueSubmit() {
+        this.saving = true;
+        try {
+          await stockBatchChange.submit({ id: this.pendingApproveId });
+          this.pendingApproveId = null;
+          this.$message.success('申请已提交,审批通过后生效');
+          this.visible = false;
+          this.$emit('success');
+        } finally {
+          this.saving = false;
+        }
+      }
+    }
+  };
+</script>
+
+<style scoped>
+  .section-title {
+    margin: 14px 0 8px;
+    font-weight: 600;
+  }
+  .merge-summary {
+    padding: 14px 0 0;
+    color: #606266;
+  }
+  .remark {
+    margin-top: 14px;
+  }
+  .saved-alert {
+    margin-top: 10px;
+  }
+  .error-text {
+    margin-top: 4px;
+    color: #f56c6c;
+    font-size: 12px;
+  }
+  .detail-table {
+    margin-top: 14px;
+  }
+</style>

+ 59 - 23
src/views/warehouseManagement/stockLedger/components/item-list.vue

@@ -79,6 +79,29 @@
             @click="handleUnlock"
             >库存解锁</el-button
           >
+          <el-button
+            v-if="selectedDime == 2"
+            size="small"
+            icon="el-icon-s-operation"
+            :disabled="checkRadioData.length !== 1"
+            @click="openBatchChange('split')"
+            >拆批申请</el-button
+          >
+          <el-button
+            v-if="selectedDime == 2"
+            size="small"
+            icon="el-icon-connection"
+            :disabled="checkRadioData.length < 2"
+            @click="openBatchChange('merge')"
+            >合批申请</el-button
+          >
+          <el-button
+            v-if="selectedDime == 2"
+            size="small"
+            icon="el-icon-document"
+            @click="openBatchChange('records')"
+            >批次变更记录</el-button
+          >
         </div>
 
         <!-- <el-button
@@ -158,7 +181,9 @@
       </template>
 
       <template v-slot:areaName="{ row }">
-        <el-link type="primary" @click="openUploadDetail(row, 'list')"> 查看 </el-link>
+        <el-link type="primary" @click="openUploadDetail(row, 'list')">
+          查看
+        </el-link>
       </template>
 
       <!-- 最小包装单元 -->
@@ -227,7 +252,7 @@
       </span>
     </el-dialog>
     <printQRCode ref="printQRCodeRef"></printQRCode>
-    <ItemListed ref="itemListedRef"/>
+    <ItemListed ref="itemListedRef" />
     <importDialog
       :defModule="moudleName"
       ref="importDialogRef"
@@ -236,6 +261,7 @@
       @success="reload"
     />
     <unlockModal ref="unlockModalRef"></unlockModal>
+    <BatchChangeDialog ref="batchChangeRef" @success="reload" />
   </div>
 </template>
 
@@ -267,7 +293,8 @@
   import { parameterGetByCode } from '@/api/sys/index.js';
   import exportButton from '@/components/upload/exportButton.vue';
   import printQRCode from '@/views/warehouseManagement/stockManagement/components/printQRCode.vue';
-  import unlockModal from '@/views/warehouseManagement/unlockApplication/components/unlockModal.vue'
+  import unlockModal from '@/views/warehouseManagement/unlockApplication/components/unlockModal.vue';
+  import BatchChangeDialog from './BatchChangeDialog.vue';
 
   export default {
     mixins: [tabMixins],
@@ -279,7 +306,8 @@
       exportButton,
       printQRCode,
       ItemListed,
-      unlockModal
+      unlockModal,
+      BatchChangeDialog
     },
     props: {
       // 机构id
@@ -455,8 +483,7 @@
               // 入库计量数量不会随出库变化,可用数量必须使用当前批次库存余额计算
               const inventoryBalance = Number(row.availableCountBase) || 0;
               const lock = Number(row.lockQuantity) || 0;
-              const result =
-                (inventoryBalance * 100 - lock * 100) / 100;
+              const result = (inventoryBalance * 100 - lock * 100) / 100;
               return Math.round(result * 100) / 100;
             }
           },
@@ -563,7 +590,7 @@
                 showOverflowTooltip: true
               }
             : '',
-            this.selectedDime == 3 || this.selectedDime == 2
+          this.selectedDime == 3 || this.selectedDime == 2
             ? {
                 prop: 'areaName',
                 label: '货架记录',
@@ -657,20 +684,22 @@
                 showOverflowTooltip: true
               }
             : '',
-          this.selectedDime != 1 ? 
-          {
-            prop: 'supplierName',
-            label: '供应商',
-            width: 120,
-            showOverflowTooltip: true
-          } : '',
-          this.selectedDime != 1 ? 
-          {
-            prop: 'supplierCode',
-            label: '供应商代号',
-            width: 120,
-            showOverflowTooltip: true
-          } : '',
+          this.selectedDime != 1
+            ? {
+                prop: 'supplierName',
+                label: '供应商',
+                width: 120,
+                showOverflowTooltip: true
+              }
+            : '',
+          this.selectedDime != 1
+            ? {
+                prop: 'supplierCode',
+                label: '供应商代号',
+                width: 120,
+                showOverflowTooltip: true
+              }
+            : '',
 
           // this.selectedDime == 1
           //   ? {
@@ -723,7 +752,14 @@
         if (this.checkRadioData?.length != 1) {
           return this.$message.warning('请选择一条数据进行解锁!');
         }
-        this.$refs.unlockModalRef.open('add',this.checkRadioData[0]);
+        this.$refs.unlockModalRef.open('add', this.checkRadioData[0]);
+      },
+      openBatchChange(mode) {
+        if (mode === 'split' && this.checkRadioData.length !== 1)
+          return this.$message.warning('拆批请选择一条库存批次');
+        if (mode === 'merge' && this.checkRadioData.length < 2)
+          return this.$message.warning('合批请选择至少两条库存批次');
+        this.$refs.batchChangeRef.open(mode, this.checkRadioData);
       },
 
       openUploadDetail(row, type) {
@@ -959,7 +995,7 @@
               warehouseAreaGoodsId:
                 this.current.data?.level == 2 ? this.current.data.id : '',
               warehouseAreaGoodsShelvesId:
-                this.current.data?.level == 3 ? this.current.data.id : '',
+                this.current.data?.level == 3 ? this.current.data.id : ''
             };
           }
           this.$refs.table.reload({