yyb
2026-05-12 324f1e7a665780eb432c3494b0c9d2f694e4f749
src/pages/inventoryManagement/scanOut/index.vue
@@ -2,7 +2,7 @@
  <view class="scan-container">
    <PageHeader title="扫码出库"
    <PageHeader title="扫码发货"
                @back="goBack" />
@@ -28,7 +28,7 @@
          <text class="module-label">合格出库</text>
          <text class="module-desc">扫描合格品进行领用出库</text>
          <text class="module-desc">记录合格品的出库流向,销售二维码扫描发货</text>
        </view>
@@ -188,25 +188,32 @@
        </view>
        <view
        <view v-if="!isFullyOutbound(item)"
              class="stocked-qty-block">
          <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"
@@ -216,9 +223,221 @@
          </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="220rpx">
          <u-form-item label="发货方式"
                       class="scan-ship-type-form-item"
                       required>
            <view class="scan-ship-type-trigger"
                  @tap.stop="openScanShipTypeSheet">
              <u-input v-model="scanShipTypeLabel"
                       readonly
                       placeholder="请选择" />
              <u-icon name="arrow-right"
                      color="#c0c4cc"
                      size="18" />
            </view>
          </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="approval-process">
          <view class="approval-header">
            <text class="approval-title">审批流程</text>
            <text class="approval-desc">每个步骤只能选择一个审批人</text>
          </view>
          <view class="approval-steps">
            <view v-for="(step, stepIndex) in scanShipApproverNodes"
                  :key="step.id"
                  class="approval-step">
              <view class="step-title">
                <text>审批人</text>
              </view>
              <view class="approver-container">
                <view v-if="step.nickName"
                      class="approver-item">
                  <view class="approver-avatar">
                    <text class="avatar-text">{{ step.nickName.charAt(0) }}</text>
                  </view>
                  <view class="approver-info">
                    <text class="approver-name">{{ step.nickName }}</text>
                  </view>
                  <view class="delete-approver-btn"
                        @tap.stop="removeScanShipApproverUser(stepIndex)">×</view>
                </view>
                <view v-else
                      class="add-approver-btn"
                      @tap.stop="openScanShipContactSelect(stepIndex)">
                  <view class="add-circle">+</view>
                  <text class="add-label">选择审批人</text>
                </view>
              </view>
              <view class="delete-step-btn"
                    v-if="scanShipApproverNodes.length > 1"
                    @tap.stop="removeScanShipApproverNode(stepIndex)">删除节点</view>
            </view>
          </view>
          <view class="add-step-btn">
            <u-button icon="plus"
                      plain
                      type="primary"
                      style="width: 100%"
                      @click="addScanShipApproverNode">新增节点</u-button>
          </view>
        </view>
        <view class="scan-ship-files">
          <text class="scan-ship-subtitle">发货附件(选填,最多10张)</text>
          <u-button class="scan-ship-add-img-btn"
                    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">
@@ -226,7 +445,17 @@
                  @click="cancelForm">返回</u-button>
        <u-button class="footer-confirm-btn"
        <u-button v-if="needScanShipFlow"
                  class="footer-confirm-btn"
                  :loading="submitLoading"
                  @click="confirmScanShipApply">提交发货审批</u-button>
        <u-button v-else
                  class="footer-confirm-btn"
                  :loading="submitLoading"
@@ -244,11 +473,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 } from "@/api/salesManagement/salesLedger";
  import { productList as salesProductList, scanShipApply } from "@/api/salesManagement/salesLedger";
  import config from "@/config";
  import { getToken } from "@/utils/auth";
  import { getLedgerShippingLabel } from "@/utils/salesLedgerShip";
  import modal from "@/plugins/modal";
@@ -261,6 +494,8 @@
    resolveListTypeForDetail,
    resolveContractNo,
    buildSalesLedgerProductList,
    buildScanShipProductList,
    resolveScanShipLineQuantity,
    hasAnyPositiveStockedQty,
    resolveSubmitSceneKey,
  } from "./scanOut.logic";
@@ -284,6 +519,241 @@
  const submitLoading = ref(false);
  /** 销售 + 合格:走「提交发货审批」,不再直接出库或跳转原发货页 */
  const needScanShipFlow = computed(
    () =>
      showForm.value &&
      type.value === QUALITY_TYPE.qualified &&
      contractKind.value === CONTRACT_KIND.sales
  );
  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("");
  let nextScanShipApproverNodeId = 2;
  const scanShipApproverNodes = ref([{ id: 1, userId: null, nickName: "" }]);
  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 openScanShipTypeSheet = () => {
    scanShipTypeSheetShow.value = true;
  };
  const addScanShipApproverNode = () => {
    scanShipApproverNodes.value.push({
      id: nextScanShipApproverNodeId++,
      userId: null,
      nickName: "",
    });
  };
  const removeScanShipApproverNode = stepIndex => {
    if (scanShipApproverNodes.value.length <= 1) return;
    scanShipApproverNodes.value.splice(stepIndex, 1);
  };
  const removeScanShipApproverUser = stepIndex => {
    const step = scanShipApproverNodes.value[stepIndex];
    if (!step) return;
    step.userId = null;
    step.nickName = "";
  };
  const openScanShipContactSelect = stepIndex => {
    uni.setStorageSync("stepIndex", stepIndex);
    uni.navigateTo({
      url: "/pages/cooperativeOffice/collaborativeApproval/contactSelect?approveType=7&source=scanShip",
    });
  };
  const handleScanShipSelectContact = data => {
    if (data?.source !== "scanShip") return;
    if (!needScanShipFlow.value) return;
    const c = data?.contact;
    if (!c) return;
    const idx = Number(data.stepIndex);
    const i = Number.isNaN(idx) ? 0 : idx;
    if (i < 0 || i >= scanShipApproverNodes.value.length) return;
    const row = scanShipApproverNodes.value[i];
    row.userId = c.userId;
    row.nickName = c.nickName || c.userName || "";
  };
  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;
    }
    const hasEmptyStep = scanShipApproverNodes.value.some(s => !s?.userId);
    if (hasEmptyStep) {
      modal.msgError("请为每个审批步骤选择审批人");
      return;
    }
    if (scanShipUploading.value) {
      modal.msgError("附件上传中,请稍候");
      return;
    }
    const approveUserIds = scanShipApproverNodes.value
      .map(s => s.userId)
      .filter(id => id != null && id !== "")
      .join(",");
    const payload = {
      salesLedgerId: scanLedgerId.value,
      salesLedgerProductList,
      approveUserIds,
      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(() => {
@@ -512,6 +982,43 @@
  };
  const parseUnqualifiedInboundQty = item => {
    return (
      parseOptionalNumber(
        item?.unqualifiedStockedQuantity ??
          item?.unQualifiedStockedQuantity ??
          item?.unqualifiedStockedQty ??
          item?.unqualifiedInboundQuantity
      ) ?? 0
    );
  };
  const parseUnqualifiedOutboundQty = item => {
    return (
      parseOptionalNumber(
        item?.unqualifiedShippedQuantity ??
          item?.unQualifiedShippedQuantity ??
          item?.unqualifiedShippedQty ??
          item?.unqualifiedOutboundQuantity
      ) ?? 0
    );
  };
  const isFullyOutbound = item => {
    if (type.value === QUALITY_TYPE.unqualified) {
      return parseUnqualifiedInboundQty(item) - parseUnqualifiedOutboundQty(item) <= 0;
    }
    const remaining = parseOptionalNumber(item?.remainingShippedQuantity);
    if (remaining !== null) return remaining <= 0;
    const fallback = parseOptionalNumber(defaultStockedQuantityFromRow(item, "outbound"));
    return fallback !== null ? fallback <= 0 : false;
  };
  const hasEditableOutboundItems = computed(() => {
    if (!recordList.value?.length) return false;
    return recordList.value.some(item => !isFullyOutbound(item));
  });
  const formatCell = (item, row, idx) => {
@@ -531,6 +1038,8 @@
    if (row.key === "productStockStatus")
      return formatProductStockStatus(item.productStockStatus);
    if (row.key === "ledgerShippingStatus") return getLedgerShippingLabel(item);
    if (row.key === "heavyBox") return formatHeavyBox(item.heavyBox);
@@ -602,6 +1111,15 @@
    recordList.value = [];
    scanShipTypeValue.value = "货车";
    scanShipCarNumber.value = "";
    scanShipExpress.value = "";
    nextScanShipApproverNodeId = 2;
    scanShipApproverNodes.value = [{ id: 1, userId: null, nickName: "" }];
    scanShipFiles.value = [];
    scanShipUploading.value = false;
    scanShipTypeSheetShow.value = false;
  };
@@ -633,6 +1151,10 @@
      return;
    }
    if (!hasEditableOutboundItems.value) {
      modal.msgError("该产品已经全部出库");
      return;
    }
    const salesLedgerProductList = buildSalesLedgerProductList(recordList.value);
@@ -692,8 +1214,6 @@
    }
  };
  const goBack = () => {
@@ -954,6 +1474,12 @@
    height: 140rpx;
    min-width: 140rpx;
    min-height: 140rpx;
    flex-shrink: 0;
    border-radius: 32rpx;
    display: flex;
@@ -993,6 +1519,10 @@
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
  }
@@ -1266,6 +1796,612 @@
  /* 发货信息:与扫码入库审批区、台账发货页表单风格一致 */
  .scan-ship-card {
    background-color: #fff;
    margin: 20rpx;
    padding: 0 0 24rpx;
    border-radius: 16rpx;
    box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
    overflow: hidden;
  }
  .scan-ship-title {
    font-size: 30rpx;
    font-weight: 600;
    color: #333;
    padding: 28rpx 28rpx 20rpx;
    border-bottom: 1rpx solid #eee;
  }
  .scan-ship-card :deep(.u-form) {
    background: transparent;
    margin: 0;
    padding: 0;
  }
  .scan-ship-card :deep(.u-form-item) {
    background: #fff;
    padding: 0 28rpx;
    border-bottom: 1rpx solid #f0f0f0;
  }
  .scan-ship-card :deep(.u-form-item:last-child) {
    border-bottom: none;
  }
  .scan-ship-card :deep(.u-form-item__body) {
    padding: 24rpx 0;
    min-height: 88rpx;
    display: flex;
    align-items: center;
    border: none;
  }
  .scan-ship-card :deep(.u-form-item__label) {
    font-size: 28rpx;
    color: #888;
    font-weight: 400;
    flex-shrink: 0;
  }
  .scan-ship-card :deep(.u-form-item__content) {
    flex: 1;
    min-width: 0;
    display: flex;
    justify-content: flex-end;
    align-items: center;
  }
  .scan-ship-card :deep(.u-input),
  .scan-ship-card :deep(.u-input input),
  .scan-ship-card :deep(.u-input__content__field-wrapper__field),
  .scan-ship-card :deep(.u-input__input) {
    border: none !important;
    box-shadow: none !important;
    background: transparent !important;
    font-size: 28rpx;
    color: #1a1a1a;
    text-align: right;
  }
  .scan-ship-card :deep(.u-input input[readonly]),
  .scan-ship-card :deep(.u-input__input[readonly]) {
    color: #666;
  }
  .scan-ship-card :deep(.scan-ship-type-form-item .u-form-item__content) {
    justify-content: flex-start;
    align-items: center;
  }
  .scan-ship-type-trigger {
    display: flex;
    flex-direction: row;
    align-items: center;
    width: 100%;
    min-height: 72rpx;
    padding: 8rpx 0;
    box-sizing: border-box;
  }
  .scan-ship-type-trigger :deep(.u-input) {
    flex: 1;
    min-width: 0;
  }
  .scan-ship-type-trigger :deep(input),
  .scan-ship-type-trigger :deep(.u-input__input) {
    pointer-events: none;
  }
  .scan-ship-card :deep(.u-input input::placeholder),
  .scan-ship-card :deep(.u-input__input::placeholder) {
    color: #c0c4cc;
    font-size: 28rpx;
  }
  .scan-ship-card :deep(.scan-ship-type-form-item .u-input input),
  .scan-ship-card :deep(.scan-ship-type-form-item .u-input__input) {
    text-align: left;
  }
  .scan-ship-card .approval-process {
    background: transparent;
    margin: 0;
    padding: 24rpx 28rpx 8rpx;
    border-radius: 0;
    box-shadow: none;
    border-top: 1rpx solid #f0f0f0;
    margin-top: 8rpx;
  }
  .scan-ship-card .approval-header {
    margin-bottom: 16rpx;
  }
  .scan-ship-card .approval-title {
    font-size: 30rpx;
    font-weight: 600;
    color: #333;
    display: block;
  }
  .scan-ship-card .approval-desc {
    font-size: 24rpx;
    color: #999;
    margin-top: 6rpx;
  }
  .scan-ship-card .approval-step {
    margin-bottom: 18rpx;
  }
  .scan-ship-card .step-title text {
    font-size: 24rpx;
    color: #666;
  }
  .scan-ship-card .approver-container {
    display: flex;
    align-items: center;
    margin-top: 10rpx;
  }
  .scan-ship-card .approver-item {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 12rpx;
    padding: 12rpx 0;
  }
  .scan-ship-card .approver-avatar {
    width: 64rpx;
    height: 64rpx;
    border-radius: 50%;
    background: #f3f4f6;
    border: 2rpx solid #e5e7eb;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .scan-ship-card .avatar-text {
    font-size: 24rpx;
    color: #374151;
    font-weight: 600;
  }
  .scan-ship-card .approver-info {
    flex: 1;
  }
  .scan-ship-card .approver-name {
    font-size: 28rpx;
    color: #333;
  }
  .scan-ship-card .delete-approver-btn {
    font-size: 32rpx;
    color: #ff4d4f;
    padding: 0 8rpx;
  }
  .scan-ship-card .add-approver-btn {
    display: flex;
    align-items: center;
    gap: 10rpx;
    color: #3b82f6;
    padding: 10rpx 0;
  }
  .scan-ship-card .add-circle {
    width: 52rpx;
    height: 52rpx;
    border: 2rpx dashed #a0aec0;
    border-radius: 50%;
    color: #6b7280;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 34rpx;
    line-height: 1;
  }
  .scan-ship-card .add-label {
    font-size: 26rpx;
  }
  .scan-ship-card .delete-step-btn {
    color: #ff4d4f;
    font-size: 24rpx;
    margin-top: 8rpx;
  }
  .scan-ship-card .add-step-btn {
    margin-top: 8rpx;
  }
  .scan-ship-subtitle {
    display: block;
    font-size: 24rpx;
    color: #666;
    margin-bottom: 16rpx;
  }
  .scan-ship-files {
    padding: 28rpx 28rpx 0;
    border-top: 1rpx solid #f0f0f0;
    margin-top: 16rpx;
  }
  .scan-ship-files :deep(.scan-ship-add-img-btn) {
    width: 100% !important;
    margin-top: 8rpx;
    border-radius: 16rpx !important;
    min-height: 88rpx;
  }
  .scan-ship-file-grid {
    display: flex;
    flex-wrap: wrap;
    gap: 24rpx;
    margin-top: 24rpx;
  }
  .scan-ship-thumb-wrap {
    position: relative;
    width: calc(50% - 12rpx);
    min-width: 200rpx;
    padding: 16rpx;
    box-sizing: border-box;
    background: #fff;
    border-radius: 16rpx;
    border: 1rpx solid #e9ecef;
    box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.05);
  }
  .scan-ship-thumb {
    width: 100%;
    height: 200rpx;
    border-radius: 12rpx;
    display: block;
    border: 2rpx solid #f0f0f0;
    background: #f5f5f5;
  }
  .scan-ship-thumb-del {
    position: absolute;
    top: 8rpx;
    right: 8rpx;
    width: 44rpx;
    height: 44rpx;
    background: #ff4757;
    color: #fff;
    font-size: 28rpx;
    line-height: 44rpx;
    text-align: center;
    border-radius: 50%;
    box-shadow: 0 4rpx 8rpx rgba(255, 71, 87, 0.35);
  }
  .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 {
    display: flex;