yyb
2026-05-12 38d723b6de39a6882a537a691159e40bd4c0e837
src/pages/inventoryManagement/scanOut/index.vue
@@ -2,7 +2,7 @@
  <view class="scan-container">
    <PageHeader title="扫码出库"
    <PageHeader title="扫码发货"
                @back="goBack" />
@@ -26,9 +26,9 @@
        <view class="module-info">
          <text class="module-label">合格出库</text>
          <text class="module-label">合格发货</text>
          <text class="module-desc">扫描合格品进行领用出库,销售扫码发货</text>
          <text class="module-desc">扫描销售订单,填写发货数量、车牌与审批人后提交发货审批(通过后自动发货)</text>
        </view>
@@ -193,19 +193,27 @@
          <view class="kv-row stocked-qty-row">
            <text class="kv-label">出库数量</text>
            <text class="kv-label">{{ needScanShipFlow ? "本次发货数量" : "出库数量" }}</text>
            <view class="kv-value stocked-qty-input-wrap">
              <up-input :key="'stocked-' + idx"
              <text v-if="needScanShipFlow"
                    class="scan-ship-qty-readonly">{{ resolveScanShipLineQuantity(item) }}</text>
              <up-input v-else
                        :key="'stocked-' + idx"
                        v-model="item.operateQuantity"
                        type="number"
                        placeholder="请输入出库数量"
                        :clearable="!isSalesQualifiedOutboundQtyLocked"
                        clearable
                        :disabled="isSalesQualifiedOutboundQtyLocked"
                        border="surround"
@@ -215,9 +223,147 @@
          </view>
          <text v-if="needScanShipFlow"
                class="scan-ship-qty-hint">整单一次性发货,数量以订单为准,不可修改</text>
        </view>
      </view>
      <view v-if="needScanShipFlow"
            class="scan-ship-card">
        <view class="scan-ship-title">发货信息</view>
        <u-form label-width="160rpx">
          <u-form-item label="发货方式"
                       required>
            <u-input v-model="scanShipTypeLabel"
                     readonly
                     placeholder="请选择"
                     @click="scanShipTypeSheetShow = true" />
          </u-form-item>
          <u-form-item v-if="scanShipTypeValue === '货车'"
                       label="车牌号"
                       required>
            <u-input v-model="scanShipCarNumber"
                     placeholder="请输入车牌号"
                     clearable />
          </u-form-item>
          <u-form-item v-if="scanShipTypeValue === '快递'"
                       label="快递单号"
                       required>
            <u-input v-model="scanShipExpress"
                     placeholder="请输入快递单号"
                     clearable />
          </u-form-item>
        </u-form>
        <view class="scan-ship-approval">
          <text class="scan-ship-subtitle">审批人</text>
          <view v-if="scanShipApprover.nickName"
                class="scan-ship-approver-pill">
            <text>{{ scanShipApprover.nickName }}</text>
            <text class="scan-ship-remove"
                  @click="clearScanShipApprover">×</text>
          </view>
          <u-button v-else
                    size="small"
                    type="primary"
                    plain
                    @click="openScanShipContactSelect">选择审批人</u-button>
        </view>
        <view class="scan-ship-files">
          <text class="scan-ship-subtitle">发货附件(选填,最多10张)</text>
          <u-button size="small"
                    type="primary"
                    :loading="scanShipUploading"
                    :disabled="scanShipFiles.length >= 10"
                    @click="chooseScanShipImage">添加图片</u-button>
          <view v-if="scanShipFiles.length"
                class="scan-ship-file-grid">
            <view v-for="(f, fi) in scanShipFiles"
                  :key="fi"
                  class="scan-ship-thumb-wrap">
              <image :src="scanShipFileUrl(f)"
                     class="scan-ship-thumb"
                     mode="aspectFill"
                     @click="previewScanShipImage(fi)" />
              <text class="scan-ship-thumb-del"
                    @click.stop="removeScanShipFile(fi)">×</text>
            </view>
          </view>
        </view>
      </view>
      <up-action-sheet :show="scanShipTypeSheetShow"
                       :actions="scanShipTypeActions"
                       title="发货方式"
                       @select="onScanShipTypeSelect"
                       @close="scanShipTypeSheetShow = false" />
      <view class="footer-btns">
@@ -225,15 +371,13 @@
                  @click="cancelForm">返回</u-button>
        <u-button v-if="showSalesShipButton"
        <u-button v-if="needScanShipFlow"
                  class="footer-confirm-btn"
                  :loading="submitLoading"
                  :disabled="salesShipButtonDisabled"
                  @click="handleSalesShipFromScan">发货</u-button>
                  @click="confirmScanShipApply">提交发货审批</u-button>
        <u-button v-else
@@ -255,16 +399,15 @@
<script setup>
  import { ref, computed } from "vue";
  import { ref, computed, onMounted, onUnmounted } from "vue";
  import PageHeader from "@/components/PageHeader.vue";
  import {
    productList as salesProductList,
    getSalesLedgerWithProducts,
  } from "@/api/salesManagement/salesLedger";
  import { productList as salesProductList, scanShipApply } from "@/api/salesManagement/salesLedger";
  import { canLedgerShip, executeSalesLedgerShip, getLedgerShippingLabel } from "@/utils/salesLedgerShip";
  import config from "@/config";
  import { getToken } from "@/utils/auth";
  import { getLedgerShippingLabel } from "@/utils/salesLedgerShip";
  import modal from "@/plugins/modal";
@@ -277,6 +420,8 @@
    resolveListTypeForDetail,
    resolveContractNo,
    buildSalesLedgerProductList,
    buildScanShipProductList,
    resolveScanShipLineQuantity,
    hasAnyPositiveStockedQty,
    resolveSubmitSceneKey,
  } from "./scanOut.logic";
@@ -298,25 +443,212 @@
  /** 二维码中的台账主键 id */
  const scanLedgerId = ref(null);
  /** 合格出库+销售码:用于「发货」按钮与台账级校验(与销售台账一致) */
  const salesLedgerSnapshotForShip = ref(null);
  const submitLoading = ref(false);
  const showSalesShipButton = computed(
  /** 销售 + 合格:走「提交发货审批」,不再直接出库或跳转原发货页 */
  const needScanShipFlow = computed(
    () =>
      showForm.value &&
      type.value === QUALITY_TYPE.qualified &&
      contractKind.value === CONTRACT_KIND.sales
  );
  const salesShipButtonDisabled = computed(() => {
    if (!showSalesShipButton.value) return false;
    const snap = salesLedgerSnapshotForShip.value;
    if (!snap) return false;
    return !canLedgerShip(snap);
  });
  const scanShipTypeValue = ref("货车");
  const scanShipTypeLabel = computed(() => scanShipTypeValue.value);
  const scanShipTypeSheetShow = ref(false);
  const scanShipTypeActions = ref([
    { name: "货车", value: "货车" },
    { name: "快递", value: "快递" },
  ]);
  const scanShipCarNumber = ref("");
  const scanShipExpress = ref("");
  const scanShipApprover = ref({ userId: null, nickName: null });
  const scanShipFiles = ref([]);
  const scanShipUploading = ref(false);
  const scanShipUploadConfig = {
    action: "/file/upload",
    limit: 10,
    fileType: ["jpg", "jpeg", "png", "gif", "webp"],
    formType: 10,
  };
  const uploadScanShipFileUrl = computed(
    () => (config.baseUrl || "").replace(/\/$/, "") + scanShipUploadConfig.action
  );
  const onScanShipTypeSelect = item => {
    scanShipTypeValue.value = item.name || item.value || "货车";
    scanShipTypeSheetShow.value = false;
    if (scanShipTypeValue.value === "货车") scanShipExpress.value = "";
    else scanShipCarNumber.value = "";
  };
  const openScanShipContactSelect = () => {
    uni.setStorageSync("stepIndex", 0);
    uni.navigateTo({
      url: "/pages/cooperativeOffice/collaborativeApproval/contactSelect?approveType=7&source=scanShip",
    });
  };
  const clearScanShipApprover = () => {
    scanShipApprover.value = { userId: null, nickName: null };
  };
  const handleScanShipSelectContact = data => {
    if (data?.source !== "scanShip") return;
    if (!needScanShipFlow.value) return;
    const c = data?.contact;
    if (!c) return;
    scanShipApprover.value = { userId: c.userId, nickName: c.nickName };
  };
  const scanShipFileUrl = file => {
    const base = config.fileUrl || "";
    const link = file?.link || file?.url || "";
    if (link && String(link).startsWith("http")) return link;
    if (link && link.startsWith("/")) return base + link;
    return file?.tempFilePath || file?.path || "";
  };
  const chooseScanShipImage = () => {
    if (scanShipFiles.value.length >= scanShipUploadConfig.limit) {
      modal.msgError(`最多${scanShipUploadConfig.limit}张`);
      return;
    }
    uni.chooseImage({
      count: 1,
      sizeType: ["compressed"],
      sourceType: ["album", "camera"],
      success: res => {
        const path = res?.tempFilePaths?.[0];
        const tf = res?.tempFiles?.[0] || {};
        if (!path) return;
        const token = getToken();
        if (!token) {
          modal.msgError("未登录");
          return;
        }
        scanShipUploading.value = true;
        uni.uploadFile({
          url: uploadScanShipFileUrl.value,
          filePath: path,
          name: "file",
          formData: { type: scanShipUploadConfig.formType },
          header: { Authorization: `Bearer ${token}` },
          success: up => {
            try {
              const body = JSON.parse(up.data || "{}");
              if (body.code === 200 && body.data) {
                const d = body.data;
                scanShipFiles.value.push({
                  tempId: d.tempId ?? d.tempFileId ?? d.id,
                  link: d.link || d.url,
                  url: d.url,
                  name: d.originalFilename || d.originalName || "图片",
                  tempFilePath: path,
                });
                modal.msgSuccess("上传成功");
              } else {
                modal.msgError(body.msg || "上传失败");
              }
            } catch (e) {
              modal.msgError("上传解析失败");
            }
          },
          fail: () => modal.msgError("上传失败"),
          complete: () => {
            scanShipUploading.value = false;
          },
        });
      },
    });
  };
  const removeScanShipFile = idx => {
    scanShipFiles.value.splice(idx, 1);
  };
  const previewScanShipImage = idx => {
    const urls = scanShipFiles.value.map(f => scanShipFileUrl(f)).filter(Boolean);
    const cur = urls[idx];
    if (urls.length && cur) uni.previewImage({ urls, current: cur });
  };
  const getScanShipTempFileIds = () =>
    scanShipFiles.value.map(f => f.tempId).filter(id => id != null && id !== "");
  const confirmScanShipApply = async () => {
    if (scanLedgerId.value == null || scanLedgerId.value === "") {
      modal.msgError("缺少订单信息,请重新扫码");
      return;
    }
    if (!hasEditableOutboundItems.value) {
      modal.msgError("该产品已经全部出库");
      return;
    }
    const salesLedgerProductList = buildScanShipProductList(recordList.value);
    if (!hasAnyPositiveStockedQty(salesLedgerProductList)) {
      modal.msgError("当前订单无可发货数量");
      return;
    }
    if (scanShipTypeValue.value === "货车" && !String(scanShipCarNumber.value || "").trim()) {
      modal.msgError("请输入车牌号");
      return;
    }
    if (scanShipTypeValue.value === "快递" && !String(scanShipExpress.value || "").trim()) {
      modal.msgError("请输入快递单号");
      return;
    }
    if (!scanShipApprover.value?.userId) {
      modal.msgError("请选择审批人");
      return;
    }
    if (scanShipUploading.value) {
      modal.msgError("附件上传中,请稍候");
      return;
    }
    const payload = {
      salesLedgerId: scanLedgerId.value,
      salesLedgerProductList,
      approveUserIds: String(scanShipApprover.value.userId),
      shipType: scanShipTypeValue.value,
      shippingCarNumber:
        scanShipTypeValue.value === "货车" ? String(scanShipCarNumber.value || "").trim() : "",
      expressNumber:
        scanShipTypeValue.value === "快递" ? String(scanShipExpress.value || "").trim() : "",
      tempFileIds: getScanShipTempFileIds(),
    };
    try {
      submitLoading.value = true;
      modal.loading("提交中...");
      const res = await scanShipApply(payload);
      modal.closeLoading();
      if (res.code === 200) {
        modal.msgSuccess(res.msg || "发货审批已发起");
        resetDetailView();
      } else {
        modal.msgError(res.msg || "提交失败");
      }
    } catch (e) {
      modal.closeLoading();
      console.error(e);
    } finally {
      submitLoading.value = false;
    }
  };
  onMounted(() => {
    uni.$on("selectContact", handleScanShipSelectContact);
  });
  onUnmounted(() => {
    uni.$off("selectContact", handleScanShipSelectContact);
  });
  const isSalesQualifiedOutboundQtyLocked = computed(
    () =>
      type.value === QUALITY_TYPE.qualified &&
      contractKind.value === CONTRACT_KIND.sales
  );
  const submitConfigByScene = createSubmitConfig(scanLedgerId);
  const cardTitleMain = computed(() => {
@@ -670,11 +1002,17 @@
    scanLedgerId.value = null;
    salesLedgerSnapshotForShip.value = null;
    expandedByIndex.value = {};
    recordList.value = [];
    scanShipTypeValue.value = "货车";
    scanShipCarNumber.value = "";
    scanShipExpress.value = "";
    scanShipApprover.value = { userId: null, nickName: null };
    scanShipFiles.value = [];
    scanShipUploading.value = false;
    scanShipTypeSheetShow.value = false;
  };
@@ -770,64 +1108,6 @@
    }
  };
  const handleSalesShipFromScan = async () => {
    if (scanLedgerId.value == null || scanLedgerId.value === "") {
      modal.msgError("缺少订单信息,请重新扫码");
      return;
    }
    if (salesLedgerSnapshotForShip.value && !canLedgerShip(salesLedgerSnapshotForShip.value)) return;
    submitLoading.value = true;
    try {
      modal.loading("加载中...");
      let ledgerRow = salesLedgerSnapshotForShip.value;
      if (!ledgerRow?.id) {
        ledgerRow = await getSalesLedgerWithProducts({ id: scanLedgerId.value, type: 1 });
      }
      modal.closeLoading();
      if (!ledgerRow?.id) {
        modal.msgError("获取台账失败");
        return;
      }
      const { productData: _pd, ...ledgerForShip } = ledgerRow;
      await executeSalesLedgerShip(ledgerForShip);
    } catch (e) {
      modal.closeLoading();
      console.error("扫码发货失败", e);
      modal.msgError("获取台账失败");
    } finally {
      submitLoading.value = false;
    }
  };
  const goBack = () => {
@@ -994,35 +1274,9 @@
        showForm.value = true;
        if (type.value === QUALITY_TYPE.qualified && kind === CONTRACT_KIND.sales) {
          salesLedgerSnapshotForShip.value = null;
          getSalesLedgerWithProducts({ id: scanData.id, type: 1 })
            .then(res => {
              salesLedgerSnapshotForShip.value = res;
            })
            .catch(() => {
              salesLedgerSnapshotForShip.value = null;
            });
        } else {
          salesLedgerSnapshotForShip.value = null;
        }
      } else {
        scanLedgerId.value = null;
        salesLedgerSnapshotForShip.value = null;
        modal.msgError("未查询到明细数据");
@@ -1033,8 +1287,6 @@
      modal.closeLoading();
      scanLedgerId.value = null;
      salesLedgerSnapshotForShip.value = null;
      console.error("处理扫码结果失败", error);
@@ -1426,6 +1678,30 @@
  }
  .scan-ship-qty-readonly {
    font-size: 30rpx;
    font-weight: 600;
    color: #1a1a1a;
  }
  .scan-ship-qty-hint {
    display: block;
    font-size: 24rpx;
    color: #999;
    padding: 8rpx 0 0;
    line-height: 1.4;
  }
  .footer-btns {