Przeglądaj źródła

福达库存盘点功能

liyuan 2 tygodni temu
rodzic
commit
3720563d4a

+ 75 - 0
src/api/exportTrade/stockInventoryCheck.js

@@ -0,0 +1,75 @@
+import request from '@/router/axios';
+
+/**
+ * 库存盘点接口前缀(后端):/business/stock/inventory/check
+ * 网关一般为:/api/trade-purchase/business/stock/inventory/check
+ */
+const PREFIX = '/api/trade-purchase/business/stock/inventory/check';
+
+/** 分页列表 */
+export function getInventoryCheckPage(current, size, params = {}) {
+  return request({
+    url: `${PREFIX}/page`,
+    method: 'get',
+    params: {
+      ...params,
+      current,
+      size
+    }
+  });
+}
+
+/** 导出(后端返回文件流时配合 responseType: blob) */
+export function exportInventoryCheckList(data) {
+  return request({
+    url: `${PREFIX}/export`,
+    method: 'post',
+    data,
+    responseType: 'blob'
+  });
+}
+
+/** 详情 */
+export function getInventoryCheckDetail(id) {
+  return request({
+    url: `${PREFIX}/detail`,
+    method: 'get',
+    params: { id }
+  });
+}
+
+/** 保存(status=0) */
+export function saveInventoryCheck(data) {
+  return request({
+    url: `${PREFIX}/save`,
+    method: 'post',
+    data
+  });
+}
+
+/** 提交(status=1) */
+export function submitInventoryCheck(data) {
+  return request({
+    url: `${PREFIX}/submit`,
+    method: 'post',
+    data
+  });
+}
+
+/** 撤销(已提交单据回退为保存态) */
+export function revokeInventoryCheck(data) {
+  return request({
+    url: `${PREFIX}/revoke`,
+    method: 'post',
+    data
+  });
+}
+
+/** 删除(仅保存态;body:{ id, version },与撤销一致) */
+export function deleteInventoryCheck(data) {
+  return request({
+    url: `${PREFIX}/delete`,
+    method: 'post',
+    data
+  });
+}

+ 3 - 0
src/enums/column-name.js

@@ -2111,6 +2111,9 @@ const columnName = [{
 }, {
   code: 486,
   name: '出口贸易-出库-列表页'
+}, {
+  code: 488,
+  name: '出口贸易-库存盘点-列表页'
 }
 ]
 export const getColumnName = (key) => {

+ 14 - 0
src/router/views/index.js

@@ -892,6 +892,20 @@ export default [{
     component: () => import( /* webpackChunkName: "views" */ '@/views/exportTrade/stockInventory/index')
   }]
 },
+// 出口 库存盘点
+{
+  path: '/exportTrade/stockInventoryCheck/index',
+  component: Layout,
+  hidden: true,
+  children: [{
+    path: '/exportTrade/stockInventoryCheck/index',
+    name: "盘点(E)",
+    meta: {
+      keepAlive: true,
+    },
+    component: () => import( /* webpackChunkName: "views" */ '@/views/exportTrade/stockInventoryCheck/index')
+  }]
+},
 // 出口 发货单详情页
 {
   path: '/exportTrade/invoice/index',

+ 102 - 0
src/views/exportTrade/stockInventoryCheck/config/mainList.json

@@ -0,0 +1,102 @@
+{
+  "searchShow": true,
+  "searchMenuSpan": 24,
+  "border": true,
+  "index": true,
+  "viewBtn": false,
+  "editBtn": false,
+  "delBtn": false,
+  "addBtn": false,
+  "headerAlign": "center",
+  "menuWidth": "200",
+  "searchLabelWidth": 100,
+  "showSummary": false,
+  "searchIcon": true,
+  "searchIndex": 2,
+  "selection": false,
+  "tip": false,
+  "column": [
+    {
+      "label": "盘点单号",
+      "prop": "formNumber",
+      "search": true,
+      "searchSpan": 6,
+      "index": 1,
+      "minWidth": 130,
+      "overHidden": true,
+      "slot": true
+    },
+    {
+      "label": "仓库",
+      "prop": "warehouse",
+      "search": true,
+      "searchSpan": 6,
+      "index": 2,
+      "minWidth": 110,
+      "overHidden": true
+    },
+    {
+      "label": "盘点日期",
+      "prop": "checkDate",
+      "search": true,
+      "searchSpan": 6,
+      "index": 3,
+      "minWidth": 110,
+      "type": "date",
+      "format": "yyyy-MM-dd",
+      "valueFormat": "yyyy-MM-dd",
+      "overHidden": true
+    },
+    {
+      "label": "状态",
+      "prop": "status",
+      "search": true,
+      "searchSpan": 6,
+      "index": 4,
+      "width": 88,
+      "type": "select",
+      "dicData": [
+        { "label": "保存", "value": 0 },
+        { "label": "提交", "value": 1 }
+      ],
+      "overHidden": true
+    },
+    {
+      "label": "差异数量合计",
+      "prop": "totalVarianceQty",
+      "search": false,
+      "index": 5,
+      "minWidth": 110,
+      "align": "right",
+      "overHidden": true
+    },
+    {
+      "label": "差异金额合计",
+      "prop": "totalVarianceAmount",
+      "search": false,
+      "index": 6,
+      "minWidth": 110,
+      "align": "right",
+      "overHidden": true
+    },
+    {
+      "label": "制单人",
+      "prop": "creator",
+      "search": false,
+      "index": 7,
+      "minWidth": 90,
+      "overHidden": true
+    },
+    {
+      "label": "制单日期",
+      "prop": "createdDate",
+      "search": false,
+      "index": 8,
+      "minWidth": 110,
+      "type": "date",
+      "format": "yyyy-MM-dd",
+      "valueFormat": "yyyy-MM-dd",
+      "overHidden": true
+    }
+  ]
+}

+ 1001 - 0
src/views/exportTrade/stockInventoryCheck/detailsPage.vue

@@ -0,0 +1,1001 @@
+<template>
+  <div class="borderless" v-loading="pageLoading">
+    <div class="customer-head">
+      <div class="customer-back">
+        <el-button
+          type="danger"
+          style="border: none; background: none; color: red"
+          icon="el-icon-arrow-left"
+          @click="backToList"
+        >返回列表
+        </el-button>
+      </div>
+      <div class="add-customer-btn">
+        <el-button
+          v-if="!isView"
+          size="small"
+          :loading="saveLoading"
+          @click="saveDraft"
+        >保存
+        </el-button>
+        <el-button
+          v-if="!isView"
+          type="primary"
+          size="small"
+          :loading="submitLoading"
+          @click="submitBill"
+        >提交
+        </el-button>
+        <el-button
+          v-if="showRevoke"
+          type="warning"
+          plain
+          size="small"
+          :loading="revokeLoading"
+          @click="handleRevoke"
+        >撤销
+        </el-button>
+      </div>
+    </div>
+
+    <div class="inventory-check-print-wrap">
+      <trade-card title="基本信息" :show-btn="false">
+        <el-form
+          ref="headerForm"
+          :model="form"
+          :rules="headerRules"
+          label-width="100px"
+          size="small"
+          :disabled="isView"
+        >
+          <el-row :gutter="16">
+            <el-col :span="6">
+              <el-form-item label="盘点单号" prop="formNumber">
+                <el-input v-model="form.formNumber" placeholder="保存后生成" disabled />
+              </el-form-item>
+            </el-col>
+            <el-col :span="6">
+              <el-form-item label="仓库" prop="warehouseId">
+                <el-select
+                  v-model="form.warehouseId"
+                  placeholder="请选择仓库(可选)"
+                  filterable
+                  clearable
+                  style="width: 100%"
+                  :loading="warehouseLoading"
+                  @change="onWarehouseChange"
+                  @visible-change="onWarehouseSelectVisible"
+                >
+                  <el-option
+                    v-for="w in warehouseOptions"
+                    :key="String(w.id)"
+                    :label="warehouseOptionLabel(w)"
+                    :value="w.id"
+                  />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="6">
+              <el-form-item label="盘点日期" prop="checkDate">
+                <el-date-picker
+                  v-model="form.checkDate"
+                  type="date"
+                  value-format="yyyy-MM-dd"
+                  placeholder="盘点业务日期"
+                  style="width: 100%"
+                />
+              </el-form-item>
+            </el-col>
+            <el-col :span="6">
+              <el-form-item label="制单人">
+                <el-input v-model="form.creator" disabled />
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="16">
+            <el-col :span="6">
+              <el-form-item label="差异数量合计">
+                <el-input :value="totalVarianceQtyDisplay" disabled />
+              </el-form-item>
+            </el-col>
+            <el-col :span="6">
+              <el-form-item label="差异金额合计">
+                <el-input :value="formatMoney(form.totalVarianceAmount)" disabled />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="备注" prop="remarks">
+                <el-input v-model="form.remarks" type="textarea" :rows="2" placeholder="备注" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+      </trade-card>
+
+      <trade-card title="盘点明细" :show-btn="false">
+        <div class="detail-toolbar">
+          <el-button
+            v-if="!isView"
+            type="primary"
+            size="small"
+            icon="el-icon-plus"
+            @click="openInventoryPickDialog"
+          >选择库存
+          </el-button>
+          <el-button
+            v-if="!isView"
+            size="small"
+            :disabled="!detailSelection.length"
+            @click="removeSelectedDetails"
+          >移除所选
+          </el-button>
+        </div>
+        <div class="inventory-check-table-scroll">
+          <el-table
+            ref="detailTable"
+            :data="detailList"
+            border
+            stripe
+            size="small"
+            max-height="440"
+            @selection-change="onDetailSelectionChange"
+          >
+            <el-table-column v-if="!isView" type="selection" width="48" align="center" />
+            <el-table-column type="index" label="#" width="46" align="center" />
+            <el-table-column prop="materialCode" label="物料编号" min-width="120" show-overflow-tooltip />
+            <el-table-column prop="materialName" label="物料名称" min-width="140" show-overflow-tooltip />
+            <el-table-column prop="specification" label="规格" min-width="100" show-overflow-tooltip />
+            <el-table-column prop="warehouse" label="仓库" min-width="100" show-overflow-tooltip />
+            <el-table-column prop="storageArea" label="库区" min-width="100" show-overflow-tooltip />
+            <el-table-column prop="supplierName" label="供应商" min-width="120" show-overflow-tooltip />
+            <el-table-column prop="unit" label="单位" width="72" align="center" />
+            <el-table-column label="库存单价" width="100" align="right" show-overflow-tooltip>
+              <template slot-scope="{ row }">
+                <span>{{ formatMoney(row.averageCostPrice) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="账面数量" width="108" align="right">
+              <template slot-scope="{ row }">
+                <span>{{ formatQty(row.bookQuantity) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="实盘数量" width="128" align="right">
+              <template slot-scope="{ row }">
+                <el-input-number
+                  v-if="!isView"
+                  v-model="row.actualQuantity"
+                  class="inv-check-qty-input"
+                  size="mini"
+                  :precision="2"
+                  :step="0.01"
+                  :min="0"
+                  :max="999999999"
+                  :controls="false"
+                  placeholder="正数,两位小数"
+                  @change="() => onActualQuantityChange(row)"
+                />
+                <span v-else>{{ formatQty(row.actualQuantity) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="差异数量" width="100" align="right">
+              <template slot-scope="{ row }">
+                <span>{{ formatQty(row.varianceQuantity) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="实盘金额" width="100" align="right">
+              <template slot-scope="{ row }">
+                <span>{{ formatMoney(row.actualAmount) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column prop="note" label="备注" min-width="100">
+              <template slot-scope="{ row }">
+                <el-input v-if="!isView" v-model="row.note" size="mini" maxlength="200" />
+                <span v-else>{{ row.note }}</span>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </trade-card>
+    </div>
+
+    <!-- 选择库存(与库存查询分页一致) -->
+    <el-dialog
+      title="选择库存"
+      :visible.sync="pickDialogVisible"
+      width="960px"
+      append-to-body
+      destroy-on-close
+      @closed="onPickDialogClosed"
+    >
+      <el-form :inline="true" size="small" class="pick-search-form" @submit.native.prevent>
+        <el-form-item label="物料编号">
+          <el-input v-model="pickQuery.materialCode" clearable placeholder="模糊" style="width: 140px" />
+        </el-form-item>
+        <el-form-item label="物料名称">
+          <el-input v-model="pickQuery.materialName" clearable placeholder="模糊" style="width: 160px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="loadPickPage(1)">查询</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table
+        ref="pickTable"
+        v-loading="pickLoading"
+        :data="pickTableData"
+        border
+        stripe
+        size="small"
+        max-height="380"
+        @selection-change="onPickSelectionChange"
+      >
+        <el-table-column type="selection" width="48" align="center" :reserve-selection="true" />
+        <el-table-column type="index" label="#" width="46" align="center" />
+        <el-table-column prop="materialCode" label="物料编号" min-width="110" show-overflow-tooltip />
+        <el-table-column prop="materialName" label="物料名称" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="warehouse" label="仓库" min-width="100" show-overflow-tooltip />
+        <el-table-column prop="storageArea" label="库区" min-width="100" show-overflow-tooltip />
+        <el-table-column prop="supplierName" label="供应商" min-width="120" show-overflow-tooltip />
+        <el-table-column prop="currentStock" label="当前库存" width="92" align="right" />
+        <el-table-column prop="averageCostPrice" label="库存均价" width="92" align="right" />
+        <el-table-column prop="unit" label="单位" width="72" align="center" />
+      </el-table>
+      <el-pagination
+        class="pick-pagination"
+        :current-page="pickPage.currentPage"
+        :page-size="pickPage.pageSize"
+        :total="pickPage.total"
+        :page-sizes="[10, 20, 50]"
+        layout="total, sizes, prev, pager, next"
+        @size-change="onPickSizeChange"
+        @current-change="onPickCurrentChange"
+      />
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="pickDialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="confirmPickInventory">确 定</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  getInventoryCheckDetail,
+  saveInventoryCheck,
+  submitInventoryCheck,
+  revokeInventoryCheck
+} from "@/api/exportTrade/stockInventoryCheck";
+import { getStockInventoryPage } from "@/api/exportTrade/stockBill";
+import { customerList as fetchStorageTypePage } from "@/api/basicData/basicStorageType";
+import _ from "lodash";
+
+const STATUS_SAVED = 0;
+const STATUS_SUBMITTED = 1;
+
+function todayStr() {
+  const d = new Date();
+  const p = n => String(n).padStart(2, "0");
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
+}
+
+/** 数量最多两位小数 */
+function normalizeQty2(v) {
+  if (v == null || v === "") {
+    return 0;
+  }
+  const n = Number(v);
+  if (!Number.isFinite(n)) {
+    return 0;
+  }
+  return Math.round(n * 100) / 100;
+}
+
+function emptyDetailRow() {
+  return {
+    inventoryId: null,
+    materialId: null,
+    materialCode: "",
+    materialName: "",
+    materialTypeId: null,
+    materialEnglish: "",
+    materialType: "",
+    productDescription: "",
+    specification: "",
+    warehouseId: null,
+    warehouse: "",
+    storageAreaId: null,
+    storageArea: "",
+    supplierId: null,
+    supplierName: "",
+    unit: "",
+    bookQuantity: 0,
+    actualQuantity: 0,
+    varianceQuantity: 0,
+    bookAmount: null,
+    actualAmount: null,
+    varianceAmount: null,
+    averageCostPrice: null,
+    note: "",
+    $rowKey: `n-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+  };
+}
+
+function recalcRowStatic(row) {
+  const book = normalizeQty2(row.bookQuantity);
+  const act = normalizeQty2(row.actualQuantity);
+  const price = Number(row.averageCostPrice);
+  const avg = Number.isFinite(price) ? price : 0;
+  row.varianceQuantity = Math.round((act - book) * 100) / 100;
+  row.bookAmount = Math.round(book * avg * 100) / 100;
+  row.actualAmount = Math.round(act * avg * 100) / 100;
+  row.varianceAmount =
+    Math.round((row.actualAmount - row.bookAmount) * 100) / 100;
+}
+
+function mapInventoryRowToDetail(inv) {
+  const invId = inv.inventoryId != null && inv.inventoryId !== "" ? inv.inventoryId : inv.id;
+  const book = normalizeQty2(inv.currentStock);
+  const price = Number(inv.averageCostPrice);
+  const avg = Number.isFinite(price) ? price : 0;
+  const actualInit = book > 0 ? book : 0.01;
+  const row = {
+    ...emptyDetailRow(),
+    inventoryId: invId,
+    materialId: inv.materialId != null ? inv.materialId : null,
+    materialCode: inv.materialCode || "",
+    materialName: inv.materialName || "",
+    materialType: inv.materialType || "",
+    materialEnglish: inv.materialEnglish || "",
+    productDescription: inv.productDescription || "",
+    specification: inv.specification || "",
+    warehouseId: inv.warehouseId != null ? inv.warehouseId : null,
+    warehouse: inv.warehouse || "",
+    storageAreaId: inv.storageAreaId != null ? inv.storageAreaId : null,
+    storageArea: inv.storageArea || "",
+    supplierId: inv.supplierId != null ? inv.supplierId : null,
+    supplierName: inv.supplierName || "",
+    unit: inv.unit || "",
+    bookQuantity: book,
+    actualQuantity: actualInit,
+    averageCostPrice: avg,
+    note: ""
+  };
+  recalcRowStatic(row);
+  return row;
+}
+
+export default {
+  name: "exportTradeStockInventoryCheckDetail",
+  props: {
+    detailData: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+  data() {
+    return {
+      pageLoading: false,
+      saveLoading: false,
+      submitLoading: false,
+      revokeLoading: false,
+      form: {
+        id: null,
+        version: null,
+        formNumber: "",
+        warehouseId: null,
+        warehouse: "",
+        checkDate: todayStr(),
+        status: STATUS_SAVED,
+        totalVarianceQty: null,
+        totalVarianceAmount: null,
+        remarks: "",
+        createdDate: todayStr(),
+        creator: ""
+      },
+      detailList: [],
+      detailSelection: [],
+      /** loadDetail 回填仓库时跳过「切换仓库清空明细」 */
+      suppressWarehouseDetailClear: false,
+      warehouseOptions: [],
+      warehouseLoading: false,
+      warehouseListLoaded: false,
+      pendingWarehouseLoad: null,
+      pickDialogVisible: false,
+      pickLoading: false,
+      pickTableData: [],
+      pickSelection: [],
+      pickQuery: {
+        materialCode: "",
+        materialName: ""
+      },
+      pickPage: {
+        currentPage: 1,
+        pageSize: 20,
+        total: 0
+      },
+      headerRules: {
+        checkDate: [{ required: true, message: "请选择盘点日期", trigger: "change" }]
+      }
+    };
+  },
+  computed: {
+    isView() {
+      if (this.detailData && this.detailData.status === 1) {
+        return true;
+      }
+      return Number(this.form.status) === STATUS_SUBMITTED;
+    },
+    showRevoke() {
+      if (this.form.id == null || this.form.id === "") {
+        return false;
+      }
+      return Number(this.form.status) === STATUS_SUBMITTED;
+    },
+    totalVarianceQtyDisplay() {
+      const v = this.form.totalVarianceQty;
+      if (v == null || v === "") {
+        return "";
+      }
+      const n = Number(v);
+      if (!Number.isFinite(n)) {
+        return String(v);
+      }
+      return String(parseFloat(n.toFixed(2)));
+    }
+  },
+  created() {
+    const u = this.$store.getters.userInfo || {};
+    this.form.creator = u.user_name || u.name || "";
+    this.loadWarehouseOptions();
+    if (this.detailData && this.detailData.id) {
+      this.loadDetail(this.detailData.id);
+    } else {
+      this.detailList = [];
+    }
+  },
+  methods: {
+    warehouseOptionLabel(w) {
+      if (!w) {
+        return "";
+      }
+      if (w.code) {
+        return `${w.cname}(${w.code})`;
+      }
+      return w.cname || "";
+    },
+    loadWarehouseOptions() {
+      if (this.warehouseListLoaded) {
+        return Promise.resolve();
+      }
+      if (this.pendingWarehouseLoad) {
+        return this.pendingWarehouseLoad;
+      }
+      this.warehouseLoading = true;
+      const pageSize = 100;
+      const loadPage = (current, acc) => {
+        return fetchStorageTypePage({
+          parentId: 0,
+          current,
+          size: pageSize
+        }).then(res => {
+          const data = res.data.data || {};
+          const records = data.records || [];
+          const total = Number(data.total) || 0;
+          const merged = acc.concat(records);
+          if (
+            records.length < pageSize ||
+            merged.length >= total ||
+            !records.length
+          ) {
+            return merged;
+          }
+          return loadPage(current + 1, merged);
+        });
+      };
+      this.pendingWarehouseLoad = loadPage(1, [])
+        .then(list => {
+          this.warehouseOptions = list;
+          this.warehouseListLoaded = true;
+        })
+        .catch(() => {
+          this.$message.error("仓库列表加载失败");
+        })
+        .finally(() => {
+          this.warehouseLoading = false;
+          this.pendingWarehouseLoad = null;
+        });
+      return this.pendingWarehouseLoad;
+    },
+    onWarehouseSelectVisible(visible) {
+      if (visible && !this.warehouseListLoaded && !this.warehouseLoading) {
+        this.loadWarehouseOptions();
+      }
+    },
+    onWarehouseChange(val) {
+      if (this.suppressWarehouseDetailClear) {
+        return;
+      }
+      if (val == null || val === "") {
+        this.form.warehouse = "";
+      } else {
+        const w = this.warehouseOptions.find(x => String(x.id) === String(val));
+        this.form.warehouse = w ? w.cname || "" : "";
+      }
+      this.clearDetailListAfterWarehouseChange();
+    },
+    /** 切换仓库后与明细行仓库/库存来源不一致,清空明细 */
+    clearDetailListAfterWarehouseChange() {
+      if (!this.detailList.length) {
+        return;
+      }
+      this.detailList = [];
+      this.detailSelection = [];
+      if (this.$refs.detailTable) {
+        this.$refs.detailTable.clearSelection();
+      }
+      this.recalcTotals();
+      this.$message.info("已切换仓库,盘点明细已清空");
+    },
+    formatMoney(v) {
+      if (v === null || v === undefined || v === "") {
+        return "";
+      }
+      const n = Number(v);
+      if (Number.isNaN(n)) {
+        return String(v);
+      }
+      return n.toFixed(2);
+    },
+    /** 数量展示:最多两位小数,去掉多余末尾 0 */
+    formatQty(v) {
+      if (v === null || v === undefined || v === "") {
+        return "";
+      }
+      const n = Number(v);
+      if (!Number.isFinite(n)) {
+        return String(v);
+      }
+      return String(parseFloat(n.toFixed(2)));
+    },
+    onActualQuantityChange(row) {
+      const v = row.actualQuantity;
+      if (v == null || v === "") {
+        recalcRowStatic(row);
+        this.recalcTotals();
+        return;
+      }
+      const n = Number(v);
+      if (!Number.isFinite(n) || n <= 0) {
+        this.$message.warning("实盘数量须为大于 0 的数字,最多两位小数");
+        this.$set(row, "actualQuantity", null);
+      } else {
+        this.$set(row, "actualQuantity", Math.round(n * 100) / 100);
+      }
+      recalcRowStatic(row);
+      this.recalcTotals();
+    },
+    backToList() {
+      this.$emit("goBack");
+    },
+    flattenDetail(raw) {
+      const list =
+        raw.detailList ||
+        raw.stockInventoryCheckDetailList ||
+        raw.businessStockInventoryCheckDetailList ||
+        [];
+      return Array.isArray(list) ? list : [];
+    },
+    loadDetail(id) {
+      if (id == null || id === "") {
+        return Promise.resolve();
+      }
+      this.pageLoading = true;
+      return getInventoryCheckDetail(id)
+        .then(res => {
+          const body = res.data || {};
+          if (body.code !== 200) {
+            this.$message.error(body.msg || "加载失败");
+            return;
+          }
+          this.suppressWarehouseDetailClear = true;
+          try {
+            const raw = body.data || {};
+            const main = raw.businessStockInventoryCheck || raw;
+            if (main.id != null) {
+              this.$set(this.form, "id", main.id);
+            }
+            if (main.version !== undefined) {
+              this.$set(this.form, "version", main.version);
+            }
+            [
+              "formNumber",
+              "warehouseId",
+              "warehouse",
+              "checkDate",
+              "status",
+              "totalVarianceQty",
+              "totalVarianceAmount",
+              "remarks",
+              "createdDate",
+              "creator"
+            ].forEach(k => {
+              if (main[k] !== undefined) {
+                this.$set(this.form, k, main[k]);
+              }
+            });
+            const list = this.flattenDetail(raw);
+            this.detailList = list.length
+              ? list.map((r, idx) => {
+                  const book = normalizeQty2(r.bookQuantity);
+                  let act =
+                    r.actualQuantity != null && r.actualQuantity !== ""
+                      ? normalizeQty2(r.actualQuantity)
+                      : book;
+                  if (act <= 0) {
+                    act = book > 0 ? book : 0.01;
+                  }
+                  const price = Number(r.averageCostPrice);
+                  const avg = Number.isFinite(price) ? price : 0;
+                  return {
+                    ...r,
+                    bookQuantity: book,
+                    actualQuantity: act,
+                    averageCostPrice: avg,
+                    $rowKey: r.id
+                      ? `id-${r.id}`
+                      : `n-${idx}-${Date.now()}`
+                  };
+                })
+              : [];
+            this.detailList.forEach(r => {
+              recalcRowStatic(r);
+            });
+            this.recalcTotals();
+          } finally {
+            this.$nextTick(() => {
+              this.suppressWarehouseDetailClear = false;
+            });
+          }
+        })
+        .catch(() => {
+          this.$message.error("详情请求失败");
+        })
+        .finally(() => {
+          this.pageLoading = false;
+        });
+    },
+    recalcRow(row) {
+      recalcRowStatic(row);
+      this.recalcTotals();
+    },
+    recalcTotals() {
+      let q = 0;
+      let a = 0;
+      this.detailList.forEach(r => {
+        q += Number(r.varianceQuantity) || 0;
+        a += Number(r.varianceAmount) || 0;
+      });
+      this.form.totalVarianceQty = Math.round(q * 100) / 100;
+      this.form.totalVarianceAmount = Math.round(a * 100) / 100;
+    },
+    onDetailSelectionChange(rows) {
+      this.detailSelection = rows;
+    },
+    removeSelectedDetails() {
+      if (!this.detailSelection.length) {
+        return;
+      }
+      const keys = new Set(this.detailSelection.map(r => r.$rowKey));
+      this.detailList = this.detailList.filter(r => !keys.has(r.$rowKey));
+      this.detailSelection = [];
+      this.recalcTotals();
+    },
+    openInventoryPickDialog() {
+      this.pickDialogVisible = true;
+      this.pickQuery = { materialCode: "", materialName: "" };
+      this.pickPage.currentPage = 1;
+      this.$nextTick(() => {
+        if (this.$refs.pickTable) {
+          this.$refs.pickTable.clearSelection();
+        }
+        this.loadPickPage(1);
+      });
+    },
+    onPickDialogClosed() {
+      this.pickTableData = [];
+      this.pickSelection = [];
+    },
+    onPickSelectionChange(rows) {
+      this.pickSelection = rows;
+    },
+    onPickSizeChange(val) {
+      this.pickPage.pageSize = val;
+      this.loadPickPage(1);
+    },
+    onPickCurrentChange(val) {
+      this.pickPage.currentPage = val;
+      this.loadPickPage(val);
+    },
+    loadPickPage(page) {
+      this.pickLoading = true;
+      const params = {
+        materialCode: this.pickQuery.materialCode || undefined,
+        materialName: this.pickQuery.materialName || undefined
+      };
+      if (this.form.warehouseId != null && this.form.warehouseId !== "") {
+        const w = this.warehouseOptions.find(
+          x => String(x.id) === String(this.form.warehouseId)
+        );
+        if (w) {
+          params.warehouseId = w.id;
+          params.warehouse = w.cname || "";
+        }
+      }
+      getStockInventoryPage(page, this.pickPage.pageSize, params)
+        .then(res => {
+          const body = res.data || {};
+          if (body.code !== 200) {
+            this.$message.error(body.msg || "库存列表加载失败");
+            this.pickTableData = [];
+            this.pickPage.total = 0;
+            return;
+          }
+          const data = body.data || {};
+          this.pickTableData = data.records || [];
+          this.pickPage.total = data.total || 0;
+          this.pickPage.currentPage = page;
+        })
+        .catch(() => {
+          this.$message.error("库存列表请求失败");
+          this.pickTableData = [];
+        })
+        .finally(() => {
+          this.pickLoading = false;
+        });
+    },
+    confirmPickInventory() {
+      if (!this.pickSelection.length) {
+        this.$message.warning("请选择库存行");
+        return;
+      }
+      const existing = new Set(
+        this.detailList.map(r =>
+          `${r.inventoryId || ""}_${r.storageAreaId || ""}_${r.materialId || ""}`
+        )
+      );
+      let added = 0;
+      this.pickSelection.forEach(inv => {
+        const row = mapInventoryRowToDetail(inv);
+        const key = `${row.inventoryId || ""}_${row.storageAreaId || ""}_${row.materialId || ""}`;
+        if (existing.has(key)) {
+          return;
+        }
+        existing.add(key);
+        this.detailList.push(row);
+        added += 1;
+      });
+      if (added) {
+        this.recalcTotals();
+        this.$message.success(`已添加 ${added} 条`);
+      } else {
+        this.$message.info("所选行已在明细中");
+      }
+      this.pickDialogVisible = false;
+    },
+    /** 已有单据 id 时保存/提交必须带 version(首次无 id 可不传) */
+    validateVersionForSave() {
+      if (this.form.id == null || this.form.id === "") {
+        return true;
+      }
+      if (
+        this.form.version === undefined ||
+        this.form.version === null ||
+        this.form.version === ""
+      ) {
+        this.$message.warning("单据缺少版本号,请刷新页面后重试");
+        return false;
+      }
+      return true;
+    },
+    buildPayload(status) {
+      this.recalcTotals();
+      const detailList = this.detailList.map((r, i) => {
+        const o = _.omit(r, ["$rowKey"]);
+        o.sort = i + 1;
+        return o;
+      });
+      const payload = {
+        ...this.form,
+        status,
+        detailList
+      };
+      const hasId = payload.id != null && payload.id !== "";
+      if (!hasId) {
+        delete payload.version;
+      } else {
+        const vn = Number(payload.version);
+        payload.version = Number.isFinite(vn) ? vn : payload.version;
+      }
+      return payload;
+    },
+    /** 撤销:与保存/提交一致,有 id 时携带并规范化 version */
+    buildRevokePayload() {
+      const payload = { ...this.form };
+      const hasId = payload.id != null && payload.id !== "";
+      if (!hasId) {
+        delete payload.version;
+        return payload;
+      }
+      const vn = Number(payload.version);
+      payload.version = Number.isFinite(vn) ? vn : payload.version;
+      return payload;
+    },
+    applyVersionFromSaveResponse(body) {
+      const d = body.data;
+      if (d && typeof d === "object" && d.version !== undefined && d.version !== null) {
+        this.$set(this.form, "version", d.version);
+      }
+    },
+    saveDraft() {
+      this.$refs.headerForm.validate(valid => {
+        if (!valid) {
+          return;
+        }
+        if (!this.validateVersionForSave()) {
+          return;
+        }
+        if (!this.detailList.length) {
+          this.$message.warning("请至少添加一条盘点明细");
+          return;
+        }
+        this.saveLoading = true;
+        saveInventoryCheck(this.buildPayload(STATUS_SAVED))
+          .then(res => {
+            const body = res.data || {};
+            if (body.code !== 200) {
+              this.$message.error(body.msg || "保存失败");
+              return;
+            }
+            this.applyVersionFromSaveResponse(body);
+            this.$message.success("保存成功");
+            const d = body.data;
+            const reloadId =
+              d && typeof d === "object" && d.id != null
+                ? d.id
+                : typeof d === "number" || typeof d === "string"
+                  ? d
+                  : this.form.id;
+            if (reloadId != null && reloadId !== "") {
+              this.loadDetail(reloadId);
+            }
+          })
+          .catch(() => {
+            this.$message.error("保存请求失败");
+          })
+          .finally(() => {
+            this.saveLoading = false;
+          });
+      });
+    },
+    submitBill() {
+      this.$refs.headerForm.validate(valid => {
+        if (!valid) {
+          return;
+        }
+        if (!this.validateVersionForSave()) {
+          return;
+        }
+        if (!this.detailList.length) {
+          this.$message.warning("请至少添加一条盘点明细");
+          return;
+        }
+        this.submitLoading = true;
+        submitInventoryCheck(this.buildPayload(STATUS_SUBMITTED))
+          .then(res => {
+            const body = res.data || {};
+            if (body.code !== 200) {
+              this.$message.error(body.msg || "提交失败");
+              return;
+            }
+            this.applyVersionFromSaveResponse(body);
+            this.$message.success("提交成功");
+            const d = body.data;
+            const reloadId =
+              d && typeof d === "object" && d.id != null
+                ? d.id
+                : typeof d === "number" || typeof d === "string"
+                  ? d
+                  : this.form.id;
+            if (reloadId != null && reloadId !== "") {
+              this.loadDetail(reloadId);
+            }
+          })
+          .catch(() => {
+            this.$message.error("提交请求失败");
+          })
+          .finally(() => {
+            this.submitLoading = false;
+          });
+      });
+    },
+    handleRevoke() {
+      if (!this.showRevoke) {
+        return;
+      }
+      const id = this.form.id;
+      if (id == null || id === "") {
+        return;
+      }
+      if (!this.validateVersionForSave()) {
+        return;
+      }
+      this.$confirm("确定撤销该盘点单?撤销后单据将回到可编辑状态。", "提示", {
+        type: "warning"
+      })
+        .then(() => {
+          this.revokeLoading = true;
+          revokeInventoryCheck(this.buildRevokePayload())
+            .then(res => {
+              const body = res.data || {};
+              if (body.code !== 200) {
+                this.$message.error(body.msg || "撤销失败");
+                return;
+              }
+              this.$message.success(body.msg || "撤销成功");
+              this.loadDetail(id);
+            })
+            .catch(() => {
+              this.$message.error("撤销请求失败");
+            })
+            .finally(() => {
+              this.revokeLoading = false;
+            });
+        })
+        .catch(() => {});
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+/* 与 variables.scss 中 fixed 顶栏配合:顶栏 top:40px + height:45px,预留足够间距避免遮挡「基本信息」等标题 */
+.customer-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 0 12px;
+}
+
+.inventory-check-print-wrap {
+  margin-top: 64px;
+  margin-bottom: 35px;
+  padding: 8px 12px 24px;
+  box-sizing: border-box;
+}
+
+.detail-toolbar {
+  margin: 8px 0 12px;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.inventory-check-table-scroll {
+  width: 100%;
+  overflow-x: auto;
+}
+
+.inv-check-qty-input {
+  width: 100%;
+}
+
+.pick-search-form {
+  margin-bottom: 12px;
+}
+
+.pick-pagination {
+  margin-top: 12px;
+  text-align: right;
+}
+</style>

+ 400 - 0
src/views/exportTrade/stockInventoryCheck/index.vue

@@ -0,0 +1,400 @@
+<template>
+  <div>
+    <basic-container v-show="show" class="page-crad">
+      <avue-crud
+        ref="crud"
+        :option="option"
+        :data="dataList"
+        v-model="form"
+        :page.sync="page"
+        :search.sync="search"
+        @search-change="searchChange"
+        @current-change="currentChange"
+        @size-change="sizeChange"
+        @refresh-change="refreshChange"
+        @on-load="onLoad"
+        :table-loading="loading"
+        :cell-style="cellStyle"
+        @search-criteria-switch="searchCriteriaSwitch"
+        @resetColumn="resetColumn"
+        @saveColumn="saveColumn"
+      >
+        <template slot="menuLeft">
+          <el-button
+            type="primary"
+            icon="el-icon-plus"
+            size="small"
+            @click.stop="openDetail()"
+          >新建盘点单
+          </el-button>
+          <el-button
+            type="warning"
+            icon="el-icon-download"
+            size="small"
+            :loading="exportLoading"
+            @click.stop="handleExport"
+          >导出
+          </el-button>
+        </template>
+        <template slot="warehouseSearch">
+          <el-select
+            v-model="search.warehouseId"
+            placeholder="仓库"
+            filterable
+            clearable
+            size="small"
+            style="width: 100%"
+            :loading="warehouseLoading"
+            @visible-change="onWarehouseSearchVisible"
+          >
+            <el-option
+              v-for="w in warehouseOptions"
+              :key="String(w.id)"
+              :label="warehouseOptionLabel(w)"
+              :value="w.id"
+            />
+          </el-select>
+        </template>
+        <template slot="checkDateSearch">
+          <el-date-picker
+            v-model="search.checkDateRange"
+            type="daterange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            format="yyyy-MM-dd"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            :default-time="['00:00:00', '23:59:59']"
+            style="width: 100%"
+            size="small"
+          />
+        </template>
+        <template slot-scope="scope" slot="formNumber">
+          <span
+            style="color: #409eff; cursor: pointer"
+            @click.stop="editOpen(scope.row, 1)"
+          >{{ scope.row.formNumber }}</span>
+        </template>
+        <template slot-scope="scope" slot="menu">
+          <el-button
+            type="text"
+            icon="el-icon-view"
+            size="small"
+            @click.stop="editOpen(scope.row, 1)"
+          >查看
+          </el-button>
+          <el-button
+            v-if="Number(scope.row.status) !== 1"
+            type="text"
+            icon="el-icon-edit"
+            size="small"
+            @click.stop="editOpen(scope.row, 0)"
+          >编辑
+          </el-button>
+          <el-button
+            v-if="Number(scope.row.status) === 0"
+            type="text"
+            icon="el-icon-delete"
+            size="small"
+            @click.stop="rowDel(scope.row)"
+          >删除
+          </el-button>
+        </template>
+      </avue-crud>
+    </basic-container>
+    <detail-page
+      v-if="!show"
+      :detail-data="detailData"
+      @goBack="goBack"
+    />
+  </div>
+</template>
+
+<script>
+import option from "./config/mainList.json";
+import {
+  getInventoryCheckPage,
+  exportInventoryCheckList,
+  deleteInventoryCheck
+} from "@/api/exportTrade/stockInventoryCheck";
+import { customerList as fetchStorageTypePage } from "@/api/basicData/basicStorageType";
+import detailPage from "./detailsPage.vue";
+import { defaultDate } from "@/util/date";
+
+export default {
+  name: "exportTradeStockInventoryCheck",
+  components: { detailPage },
+  data() {
+    return {
+      search: {
+        warehouseId: null,
+        checkDateRange: defaultDate()
+      },
+      form: {},
+      option: {},
+      dataList: [],
+      page: {
+        pageSize: 20,
+        currentPage: 1,
+        total: 0,
+        pageSizes: [10, 20, 30, 40, 50, 100, 200, 300, 400, 500]
+      },
+      show: true,
+      detailData: {},
+      loading: false,
+      exportLoading: false,
+      warehouseOptions: [],
+      warehouseLoading: false,
+      warehouseListLoaded: false,
+      pendingWarehouseLoad: null
+    };
+  },
+  async created() {
+    this.option = await this.getColumnData(this.getColumnName(488), option);
+    this.option.height = window.innerHeight - 210;
+  },
+  methods: {
+    searchCriteriaSwitch(type) {
+      if (type) {
+        this.option.height = this.option.height - 191;
+      } else {
+        this.option.height = this.option.height + 191;
+      }
+      this.$refs.crud.getTableHeight();
+    },
+    cellStyle() {
+      return "padding:0;height:40px;";
+    },
+    rowDel(row) {
+      if (row.id == null || row.id === "") {
+        this.$message.warning("无法删除:缺少单据 id");
+        return;
+      }
+      if (Number(row.status) !== 0) {
+        this.$message.warning("仅保存状态的单据可删除");
+        return;
+      }
+      if (
+        row.version === undefined ||
+        row.version === null ||
+        row.version === ""
+      ) {
+        this.$message.warning("缺少版本号,请刷新列表后重试");
+        return;
+      }
+      const vn = Number(row.version);
+      const version = Number.isFinite(vn) ? vn : row.version;
+      this.$confirm("确定删除该盘点单?", "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      })
+        .then(() => {
+          deleteInventoryCheck({ id: row.id, version })
+            .then(res => {
+              const body = res.data || {};
+              if (body.code === 200) {
+                this.$message.success(body.msg || "删除成功");
+                this.onLoad(this.page, this.search);
+              } else {
+                this.$message.error(body.msg || "删除失败");
+              }
+            })
+            .catch(() => {
+              this.$message.error("删除请求失败");
+            });
+        })
+        .catch(() => {});
+    },
+    warehouseOptionLabel(w) {
+      if (!w) {
+        return "";
+      }
+      if (w.code) {
+        return `${w.cname}(${w.code})`;
+      }
+      return w.cname || "";
+    },
+    loadWarehouseOptionsForSearch() {
+      if (this.warehouseListLoaded) {
+        return Promise.resolve();
+      }
+      if (this.pendingWarehouseLoad) {
+        return this.pendingWarehouseLoad;
+      }
+      this.warehouseLoading = true;
+      const pageSize = 100;
+      const loadPage = (current, acc) => {
+        return fetchStorageTypePage({
+          parentId: 0,
+          current,
+          size: pageSize
+        }).then(res => {
+          const data = res.data.data || {};
+          const records = data.records || [];
+          const total = Number(data.total) || 0;
+          const merged = acc.concat(records);
+          if (
+            records.length < pageSize ||
+            merged.length >= total ||
+            !records.length
+          ) {
+            return merged;
+          }
+          return loadPage(current + 1, merged);
+        });
+      };
+      this.pendingWarehouseLoad = loadPage(1, [])
+        .then(list => {
+          this.warehouseOptions = list;
+          this.warehouseListLoaded = true;
+        })
+        .catch(() => {
+          this.$message.error("仓库列表加载失败");
+        })
+        .finally(() => {
+          this.warehouseLoading = false;
+          this.pendingWarehouseLoad = null;
+        });
+      return this.pendingWarehouseLoad;
+    },
+    onWarehouseSearchVisible(visible) {
+      if (visible && !this.warehouseListLoaded && !this.warehouseLoading) {
+        this.loadWarehouseOptionsForSearch();
+      }
+    },
+    mergeWarehouseSearchParams(p) {
+      if (
+        this.search.warehouseId != null &&
+        this.search.warehouseId !== ""
+      ) {
+        const w = this.warehouseOptions.find(
+          x => String(x.id) === String(this.search.warehouseId)
+        );
+        if (w) {
+          p.warehouseId = w.id;
+          p.warehouse = w.cname || "";
+        }
+      }
+    },
+    searchChange(params, done) {
+      const p = { ...params };
+      this.mergeWarehouseSearchParams(p);
+      if (
+        this.search.checkDateRange &&
+        this.search.checkDateRange.length === 2
+      ) {
+        p.checkDateStart = this.search.checkDateRange[0].slice(0, 10);
+        p.checkDateEnd = this.search.checkDateRange[1].slice(0, 10);
+      }
+      delete p.checkDate;
+      delete p.checkDateRange;
+      this.page.currentPage = 1;
+      this.onLoad(this.page, p);
+      done();
+    },
+    currentChange(val) {
+      this.page.currentPage = val;
+    },
+    sizeChange(val) {
+      this.page.currentPage = 1;
+      this.page.pageSize = val;
+    },
+    onLoad(page, params = {}) {
+      const needWarehouseOpts =
+        this.search.warehouseId != null &&
+        this.search.warehouseId !== "" &&
+        !this.warehouseOptions.some(
+          x => String(x.id) === String(this.search.warehouseId)
+        );
+      const fetchPage = () => {
+        const p = { ...params };
+        this.mergeWarehouseSearchParams(p);
+        if (
+          this.search.checkDateRange &&
+          this.search.checkDateRange.length === 2
+        ) {
+          p.checkDateStart = this.search.checkDateRange[0].slice(0, 10);
+          p.checkDateEnd = this.search.checkDateRange[1].slice(0, 10);
+        }
+        this.loading = true;
+        getInventoryCheckPage(page.currentPage, page.pageSize, p)
+          .then(res => {
+            const body = res.data || {};
+            if (body.code !== 200) {
+              this.$message.error(body.msg || "加载失败");
+              this.dataList = [];
+              this.page.total = 0;
+              return;
+            }
+            const data = body.data || {};
+            this.dataList = data.records || [];
+            this.page.total = data.total || 0;
+          })
+          .catch(() => {
+            this.$message.error("列表请求失败");
+            this.dataList = [];
+            this.page.total = 0;
+          })
+          .finally(() => {
+            this.loading = false;
+          });
+      };
+      if (needWarehouseOpts) {
+        this.loadWarehouseOptionsForSearch().finally(fetchPage);
+      } else {
+        fetchPage();
+      }
+    },
+    refreshChange() {
+      this.onLoad(this.page, this.search);
+    },
+    openDetail() {
+      this.detailData = {};
+      this.show = false;
+    },
+    editOpen(row, status) {
+      this.detailData = { id: row.id, status };
+      this.show = false;
+    },
+    goBack() {
+      this.detailData = this.$options.data().detailData;
+      this.show = true;
+      this.onLoad(this.page, this.search);
+    },
+    handleExport() {
+      const p = { ...this.search };
+      this.mergeWarehouseSearchParams(p);
+      if (
+        this.search.checkDateRange &&
+        this.search.checkDateRange.length === 2
+      ) {
+        p.checkDateStart = this.search.checkDateRange[0].slice(0, 10);
+        p.checkDateEnd = this.search.checkDateRange[1].slice(0, 10);
+      }
+      delete p.checkDateRange;
+      this.exportLoading = true;
+      exportInventoryCheckList(p)
+        .then(res => {
+          const blob = res.data;
+          if (!(blob instanceof Blob) || blob.size === 0) {
+            this.$message.warning("导出接口待返回文件或暂无数据");
+            return;
+          }
+          const url = window.URL.createObjectURL(blob);
+          const a = document.createElement("a");
+          a.href = url;
+          a.download = `库存盘点_${Date.now()}.xlsx`;
+          a.click();
+          window.URL.revokeObjectURL(url);
+          this.$message.success("导出成功");
+        })
+        .catch(() => {
+          this.$message.warning("导出接口待后端对接或请求失败");
+        })
+        .finally(() => {
+          this.exportLoading = false;
+        });
+    }
+  }
+};
+</script>