yyb
2026-05-12 38d723b6de39a6882a537a691159e40bd4c0e837
src/pages/inventoryManagement/scanOut/index.vue
@@ -1,339 +1,1750 @@
<template>
  <view class="scan-container">
    <PageHeader title="扫码出库"
    <PageHeader title="扫码发货"
                @back="goBack" />
    <view class="module-selector"
          v-if="!showForm">
      <view class="module-card"
            @click="startScan('qualified')">
        <view class="module-icon qualified">
          <u-icon name="checkbox-mark"
                  color="#fff"
                  size="40"></u-icon>
        </view>
        <view class="module-info">
          <text class="module-label">合格出库</text>
          <text class="module-desc">扫描合格品进行领用出库</text>
          <text class="module-label">合格发货</text>
          <text class="module-desc">扫描销售订单,填写发货数量、车牌与审批人后提交发货审批(通过后自动发货)</text>
        </view>
      </view>
      <view class="module-card"
            @click="startScan('unqualified')">
        <view class="module-icon unqualified">
          <u-icon name="close"
                  color="#fff"
                  size="40"></u-icon>
        </view>
        <view class="module-info">
          <text class="module-label">不合格出库</text>
          <text class="module-desc">记录不合格品的出库流向</text>
        </view>
      </view>
    </view>
    <view class="form-content"
          v-if="showForm">
      <u-form ref="formRef"
              :model="form"
              :rules="formRules"
              label-width="100px">
        <u-form-item label="出库类型"
                     border-bottom>
          <u-tag :text="type === 'qualified' ? '合格出库' : '不合格出库'"
                 :type="type === 'qualified' ? 'success' : 'error'"></u-tag>
        </u-form-item>
        <u-form-item label="产品名称"
                     border-bottom>
          <u-input v-model="form.productName"
                   readonly
                   border="none"></u-input>
        </u-form-item>
        <u-form-item label="规格型号"
                     border-bottom>
          <u-input v-model="form.model"
                   readonly
                   border="none"></u-input>
        </u-form-item>
        <u-form-item label="可用库存"
                     border-bottom>
          <u-input v-model="form.unLockedQuantity"
                   readonly
                   border="none"></u-input>{{form.unit}}
        </u-form-item>
        <u-form-item label="出库数量"
                     prop="qualitity"
                     required
                     border-bottom>
          <u-number-box v-model="form.qualitity"
                        :min="1"
                        :max="form.unLockedQuantity"
                        :step="1"></u-number-box>
          <text class="limit-tip">最大可领用: {{form.unLockedQuantity}}</text>
        </u-form-item>
        <u-form-item label="备注"
                     prop="remark"
                     border-bottom>
          <u-textarea v-model="form.remark"
                      placeholder="请输入备注"
                      count></u-textarea>
        </u-form-item>
      </u-form>
    <scroll-view v-if="showForm"
                 scroll-y
                 class="detail-scroll">
      <view class="detail-card"
            v-for="(item, idx) in recordList"
            :key="idx">
        <view class="detail-card-title"
              :class="{
                'detail-card-title--collapsible': recordList.length > 1,
                'is-collapsed': recordList.length > 1 && !isCardExpanded(idx),
              }"
              @click="recordList.length > 1 && toggleCardDetail(idx)">
          <view class="detail-card-title-text">
            <text class="detail-card-title-no">{{ cardTitleMain }}</text>
            <text v-if="recordList.length > 1"
                  class="detail-card-title-seq">({{ idx + 1 }}/{{ recordList.length }})</text>
          </view>
          <u-icon v-if="recordList.length > 1"
                  :name="isCardExpanded(idx) ? 'arrow-up' : 'arrow-down'"
                  color="#999"
                  size="18"></u-icon>
        </view>
        <view v-show="recordList.length > 1 && !isCardExpanded(idx)"
              class="detail-card-summary"
              @click="toggleCardDetail(idx)">
          <view class="kv-row kv-row--summary"
                v-for="row in summaryFieldRows"
                :key="'sum-' + row.key">
            <text class="kv-label">{{ row.label }}</text>
            <view class="kv-value kv-value--tag"
                  v-if="row.key === 'approveStatus'">
              <u-tag :type="approveStatusTagType(item)"
                     size="small">{{ formatApproveStatus(item) }}</u-tag>
            </view>
            <view class="kv-value kv-value--tag"
                  v-else-if="row.key === 'productStockStatus'">
              <u-tag :type="productStockStatusTagType(item.productStockStatus)"
                     size="small">{{ formatProductStockStatus(item.productStockStatus) }}</u-tag>
            </view>
            <text class="kv-value"
                  v-else>{{ formatCell(item, row, idx) }}</text>
          </view>
          <text class="summary-tip">点击查看全部</text>
        </view>
        <view v-show="isCardExpanded(idx)"
              class="detail-card-body">
          <view class="kv-row"
                v-for="row in detailFieldRows"
                :key="row.key">
            <text class="kv-label">{{ row.label }}</text>
            <view class="kv-value kv-value--tag"
                  v-if="row.key === 'approveStatus'">
              <u-tag :type="approveStatusTagType(item)"
                     size="small">{{ formatApproveStatus(item) }}</u-tag>
            </view>
            <view class="kv-value kv-value--tag"
                  v-else-if="row.key === 'productStockStatus'">
              <u-tag :type="productStockStatusTagType(item.productStockStatus)"
                     size="small">{{ formatProductStockStatus(item.productStockStatus) }}</u-tag>
            </view>
            <text class="kv-value"
                  v-else>{{ formatCell(item, row, idx) }}</text>
          </view>
        </view>
        <view v-if="!isFullyOutbound(item)"
              class="stocked-qty-block">
          <view class="kv-row stocked-qty-row">
            <text class="kv-label">{{ needScanShipFlow ? "本次发货数量" : "出库数量" }}</text>
            <view class="kv-value stocked-qty-input-wrap">
              <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"
                        :disabled="isSalesQualifiedOutboundQtyLocked"
                        border="surround"
                        @blur="onStockedQtyBlur(item)" />
            </view>
          </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">
        <u-button class="cancel-btn"
                  @click="cancelForm">取消</u-button>
        <u-button class="save-btn"
                  @click="handleSubmit"
                  :loading="loading">确认出库</u-button>
        <u-button class="footer-cancel-btn"
                  @click="cancelForm">返回</u-button>
        <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"
                  @click="confirmOutbound">确认</u-button>
      </view>
    </view>
    </scroll-view>
  </view>
</template>
<script setup>
  import { ref, reactive } from "vue";
  import { ref, computed, onMounted, onUnmounted } from "vue";
  import PageHeader from "@/components/PageHeader.vue";
  import {
    subtractStockInventory,
    getStockInventoryListPage,
  } from "@/api/inventoryManagement/stockInventory.js";
  import {
    subtractStockUnInventory,
    getStockUninventoryListPage,
  } from "@/api/inventoryManagement/stockUninventory.js";
  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";
  import { QUALITY_TYPE, CONTRACT_KIND } from "./scanOut.constants";
  import { useScanOutFieldRows } from "./scanOut.fields";
  import {
    parseOptionalNumber,
    defaultStockedQuantityFromRow,
    resolveQrContractKind,
    resolveListTypeForDetail,
    resolveContractNo,
    buildSalesLedgerProductList,
    buildScanShipProductList,
    resolveScanShipLineQuantity,
    hasAnyPositiveStockedQty,
    resolveSubmitSceneKey,
  } from "./scanOut.logic";
  import { createSubmitConfig } from "./scanOut.submit";
  const showForm = ref(false);
  const type = ref("qualified"); // qualified | unqualified
  const loading = ref(false);
  const formRef = ref(null);
  const form = ref({
    id: undefined,
    productId: undefined,
    productModelId: undefined,
    productName: "",
    model: "",
    unit: "",
    qualitity: 1,
    unLockedQuantity: 0,
    remark: "",
  });
  const type = ref(QUALITY_TYPE.qualified);
  const formRules = {
    qualitity: [
      {
        required: true,
        type: "number",
        message: "请输入出库数量",
        trigger: ["blur", "change"],
      },
      {
        validator: (rule, value, callback) => {
          if (value > form.value.unLockedQuantity) {
            callback(new Error("不能超过可用库存"));
          } else {
            callback();
          }
        },
        trigger: ["blur", "change"],
      },
    ],
  const recordList = ref([]);
  const expandedByIndex = ref({});
  const scanContractNo = ref("");
  /** 扫码合同类型:销售台账 / 采购台账 */
  const contractKind = ref(CONTRACT_KIND.sales);
  /** 二维码中的台账主键 id */
  const scanLedgerId = ref(null);
  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("");
  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 goBack = () => {
    if (showForm.value) {
      showForm.value = false;
    } else {
      uni.navigateBack();
  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;
    }
  };
  const cancelForm = () => {
    showForm.value = false;
  };
  const startScan = scanType => {
    type.value = scanType;
    uni.scanCode({
    uni.chooseImage({
      count: 1,
      sizeType: ["compressed"],
      sourceType: ["album", "camera"],
      success: res => {
        handleScanResult(res.result);
      },
      fail: err => {
        modal.msgError("扫码失败");
        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 handleScanResult = async result => {
  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 {
      // 解析二维码数据
      const scanData = JSON.parse(result);
      if (!scanData.id) {
        modal.msgError("无效的二维码数据");
        return;
      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(() => {
    const no = scanContractNo.value?.trim();
    return no || "—";
  });
  const isCardExpanded = idx => {
    if (recordList.value.length <= 1) return true;
    return !!expandedByIndex.value[idx];
  };
  const toggleCardDetail = idx => {
    if (recordList.value.length <= 1) return;
    expandedByIndex.value = {
      ...expandedByIndex.value,
      [idx]: !expandedByIndex.value[idx],
    };
  };
  const { detailFieldRows: rawDetailFieldRows, summaryFieldRows: rawSummaryFieldRows } = useScanOutFieldRows(
    contractKind,
    "outbound"
  );
  const shouldShowOutboundQuantityField = key => {
    if (type.value === QUALITY_TYPE.qualified)
      return key !== "unqualifiedShippedQuantity" && key !== "unqualifiedStockedQuantity";
    if (type.value === QUALITY_TYPE.unqualified) return key !== "shippedQuantity" && key !== "remainingShippedQuantity";
    return true;
  };
  const detailFieldRows = computed(() =>
    rawDetailFieldRows.value.filter(row => shouldShowOutboundQuantityField(row.key))
  );
  const summaryFieldRows = computed(() =>
    rawSummaryFieldRows.value.filter(row => shouldShowOutboundQuantityField(row.key))
  );
  const emptyDash = v => {
    if (v === null || v === undefined || v === "") return "-";
    return v;
  };
  const formatApproveStatus = row => {
    const a = row.approveStatus;
    const noShipInfo = !row.shippingDate || !row.shippingCarNumber;
    const hasShipInfo = !!(row.shippingDate || row.shippingCarNumber);
    if ((a === 1 || a === "1") && noShipInfo) return "充足";
    if ((a === 0 || a === "0") && hasShipInfo) return "已出库";
    return "不足";
  };
  const formatProductStockStatus = v => {
    if (v == 1) return "部分入库";
    if (v == 2) return "已入库";
    if (v == 0) return "未出库";
    return "不足";
  };
  const approveStatusTagType = row => {
    const a = row.approveStatus;
    const noShipInfo = !row.shippingDate || !row.shippingCarNumber;
    const hasShipInfo = !!(row.shippingDate || row.shippingCarNumber);
    if ((a === 1 || a === "1") && noShipInfo) return "success";
    if ((a === 0 || a === "0") && hasShipInfo) return "success";
    return "error";
  };
  const productStockStatusTagType = v => {
    if (v == 1) return "warning";
    if (v == 2) return "success";
    if (v == 0) return "info";
    return "error";
  };
  const formatHeavyBox = v => {
    if (v === 1 || v === true || v === "1") return "是";
    if (v === 0 || v === false || v === "0") return "否";
    return emptyDash(v);
  };
  const parseOptionalNumberLocal = raw => {
    if (raw === null || raw === undefined || raw === "") return null;
    const n = Number(String(raw).trim());
    return Number.isNaN(n) ? null : n;
  };
  const parseRemainingQuantityLocal = row => {
    const remRaw =
      row?.remainingQuantity ??
      row?.remaining_quantity ??
      row?.remainQuantity ??
      row?.remain_quantity;
    return parseOptionalNumberLocal(remRaw);
  };
  const defaultStockedQuantityFromRowLocal = row => {
    const rem = parseRemainingQuantityLocal(row);
    if (rem !== null) return String(Math.max(0, rem));
    const avail = parseOptionalNumberLocal(
      row?.availableQuality ?? row?.availableQuantity
    );
    if (avail !== null) return String(Math.max(0, avail));
    const qty = parseOptionalNumberLocal(row?.quantity);
    if (qty !== null) return String(Math.max(0, qty));
    return "0";
  };
  const onStockedQtyBlur = item => {
    const raw = item.operateQuantity;
    if (raw === null || raw === undefined || String(raw).trim() === "") {
      item.operateQuantity = "0";
      return;
    }
    const n = Number(String(raw).trim());
    if (Number.isNaN(n)) {
      if (type.value === QUALITY_TYPE.unqualified) {
        const unqualifiedInbound = parseOptionalNumber(item.unqualifiedStockedQuantity) ?? 0;
        const unqualifiedOutbound = parseOptionalNumber(item.unqualifiedShippedQuantity) ?? 0;
        item.operateQuantity = String(Math.max(0, unqualifiedInbound - unqualifiedOutbound));
      } else {
        item.operateQuantity = defaultStockedQuantityFromRow(item, "outbound");
      }
      // 获取实时库存详情
      modal.loading("获取产品库存详情...");
      const apiCall =
        type.value === "qualified"
          ? getStockInventoryListPage
          : getStockUninventoryListPage;
      return;
      const res = await apiCall({ productModelId: scanData.id });
    }
    item.operateQuantity = String(Math.max(0, n));
  };
  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) => {
    if (row.key === "index") {
      const v = item.index;
      if (v !== null && v !== undefined && v !== "") return String(v);
      return String(idx + 1);
    }
    if (row.key === "approveStatus") return formatApproveStatus(item);
    if (row.key === "productStockStatus")
      return formatProductStockStatus(item.productStockStatus);
    if (row.key === "ledgerShippingStatus") return getLedgerShippingLabel(item);
    if (row.key === "heavyBox") return formatHeavyBox(item.heavyBox);
    if (row.key === "remainingQuantity") {
      const v = item.remainingQuantity;
      return emptyDash(v);
    }
    if (row.key === "remainingShippedQuantity") {
      const v = item.remainingShippedQuantity;
      return emptyDash(v);
    }
    if (row.key === "shippedQuantity") {
      const v = item.shippedQuantity;
      return emptyDash(v);
    }
    if (row.key === "unqualifiedShippedQuantity") {
      const v =
        item.unqualifiedShippedQuantity ??
        item.unQualifiedShippedQuantity ??
        item.unqualifiedShippedQty ??
        item.unqualifiedOutboundQuantity;
      return emptyDash(v);
    }
    if (row.key === "stockedQuantity") {
      const v = item.stockedQuantity;
      return emptyDash(v);
    }
    if (row.key === "unqualifiedStockedQuantity") {
      const v = item.unqualifiedStockedQuantity;
      return emptyDash(v);
    }
    if (row.key === "availableQuality") {
      const v = item.availableQuality ?? item.availableQuantity;
      return emptyDash(v);
    }
    if (row.key === "returnQuality") {
      const v = item.returnQuality ?? item.returnQuantity;
      return emptyDash(v);
    }
    return emptyDash(item[row.key]);
  };
  const resetDetailView = () => {
    showForm.value = false;
    scanContractNo.value = "";
    contractKind.value = CONTRACT_KIND.sales;
    scanLedgerId.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;
  };
  /** 组装提交用的产品行(含数值化出库数量 stockedQuantity) */
  const buildSalesLedgerProductListLocal = () => {
    return recordList.value.map(item => {
      const n = parseOptionalNumber(item.stockedQuantity);
      const qty = n !== null && !Number.isNaN(n) ? Math.max(0, n) : 0;
      const { stockedQuantity: _sq, ...rest } = item;
      return { ...rest, stockedQuantity: qty };
    });
  };
  const confirmOutbound = async () => {
    if (scanLedgerId.value == null || scanLedgerId.value === "") {
      modal.msgError("缺少订单信息,请重新扫码");
      return;
    }
    if (!hasEditableOutboundItems.value) {
      modal.msgError("该产品已经全部出库");
      return;
    }
    const salesLedgerProductList = buildSalesLedgerProductList(recordList.value);
    if (!hasAnyPositiveStockedQty(salesLedgerProductList)) {
      modal.msgError("请至少填写一行大于 0 的出库数量");
      return;
    }
    const sceneKey = resolveSubmitSceneKey(contractKind.value, type.value);
    const currentSubmitConfig = submitConfigByScene[sceneKey];
    if (!currentSubmitConfig) {
      modal.msgError("暂不支持当前出库场景");
      return;
    }
    const runApi = currentSubmitConfig.runApi;
    const payload = currentSubmitConfig.payloadBuilder(salesLedgerProductList);
    try {
      submitLoading.value = true;
      modal.loading("提交中...");
      const res = await runApi(payload);
      modal.closeLoading();
      if (res.code === 200 && res.data.records && res.data.records.length > 0) {
        const detail = res.data.records[0];
        form.value.id = detail.id;
        form.value.productId = detail.productId;
        form.value.productName = detail.productName;
        form.value.productModelId = detail.productModelId;
        form.value.model = detail.model;
        form.value.unit = detail.unit;
        form.value.unLockedQuantity = detail.unLockedQuantity;
        form.value.qualitity = 1;
        form.value.remark = "";
      if (res.code === 200) {
        if (form.value.unLockedQuantity <= 0) {
          modal.msgError("当前库存不足,无法出库");
          return;
        }
        modal.msgSuccess("提交成功");
        resetDetailView();
      } else {
        modal.msgError(res.msg || "提交失败");
      }
    } catch (e) {
      modal.closeLoading();
      console.error("扫码出库提交失败", e);
    } finally {
      submitLoading.value = false;
    }
  };
  const goBack = () => {
    if (showForm.value) {
      resetDetailView();
    } else {
      uni.navigateBack();
    }
  };
  const cancelForm = () => {
    resetDetailView();
  };
  const startScan = scanType => {
    type.value = scanType;
    uni.scanCode({
      success: res => {
        handleScanResult(res.result);
      },
      fail: () => {
        modal.msgError("扫码失败");
      },
    });
  };
  /** 根据二维码 JSON 判断销售(XS)/采购(CG),与接口 type:1 销售、2 采购 对应 */
  const resolveQrContractKindLocal = scanData => {
    const t = scanData?.type;
    const ts =
      t !== null && t !== undefined && t !== ""
        ? String(t).trim().toUpperCase()
        : "";
    if (ts === "CG" || t === 2 || t === "2") return CONTRACT_KIND.purchase;
    if (ts === "XS" || t === 1 || t === "1") return CONTRACT_KIND.sales;
    const pc = scanData?.purchaseContractNumber;
    const sc = scanData?.salesContractNo;
    if (
      pc != null &&
      String(pc).trim() !== "" &&
      (sc == null || String(sc).trim() === "")
    )
      return CONTRACT_KIND.purchase;
    return CONTRACT_KIND.sales;
  };
  const handleScanResult = async result => {
    try {
      const scanData = JSON.parse(result);
      if (!scanData.id) {
        modal.msgError("无效的二维码数据");
        return;
      }
      const kind = resolveQrContractKind(scanData);
      contractKind.value = kind;
      scanLedgerId.value = scanData.id;
      scanContractNo.value = resolveContractNo(scanData, kind);
      const listType = resolveListTypeForDetail(kind);
      modal.loading("获取产品库存详情...");
      const res = await salesProductList({
        salesLedgerId: scanData.id,
        type: listType,
      });
      modal.closeLoading();
      if (res.code === 200 && res.data && res.data.length > 0) {
        recordList.value = res.data.map(row => ({
          ...row,
          unqualifiedShippedQuantity:
            row.unqualifiedShippedQuantity ??
            row.unQualifiedShippedQuantity ??
            row.unqualifiedShippedQty ??
            row.unqualifiedOutboundQuantity,
          unqualifiedStockedQuantity:
            row.unqualifiedStockedQuantity ??
            row.unQualifiedStockedQuantity ??
            row.unqualifiedStockedQty ??
            row.unqualifiedInboundQuantity,
          operateQuantity:
            type.value === QUALITY_TYPE.unqualified
              ? String(
                  Math.max(
                    0,
                    (parseOptionalNumber(
                      row.unqualifiedStockedQuantity ??
                        row.unQualifiedStockedQuantity ??
                        row.unqualifiedStockedQty ??
                        row.unqualifiedInboundQuantity
                    ) ?? 0) -
                      (parseOptionalNumber(
                        row.unqualifiedShippedQuantity ??
                          row.unQualifiedShippedQuantity ??
                          row.unqualifiedShippedQty ??
                          row.unqualifiedOutboundQuantity
                      ) ?? 0)
                  )
                )
              : defaultStockedQuantityFromRow(row, "outbound"),
        }));
        expandedByIndex.value = {};
        showForm.value = true;
      } else {
        modal.msgError("未找到该产品型号的库存记录");
        scanLedgerId.value = null;
        modal.msgError("未查询到明细数据");
      }
    } catch (error) {
      modal.closeLoading();
      scanLedgerId.value = null;
      console.error("处理扫码结果失败", error);
      modal.msgError("扫码处理失败,请重试");
    }
  };
  const handleSubmit = async () => {
    try {
      const valid = await formRef.value.validate();
      if (!valid) return;
      loading.value = true;
      const apiCall =
        type.value === "qualified"
          ? subtractStockInventory
          : subtractStockUnInventory;
      const res = await apiCall(form.value);
      if (res.code === 200) {
        modal.msgSuccess("出库成功");
        setTimeout(() => {
          showForm.value = false;
        }, 1500);
      }
    } catch (error) {
      console.error("提交失败", error);
    } finally {
      loading.value = false;
    }
  };
</script>
<style scoped lang="scss">
  .scan-container {
    min-height: 100vh;
    background-color: #f5f7fa;
  }
  .module-selector {
    display: flex;
    flex-direction: column;
    padding: 40rpx;
    height: 80vh;
    justify-content: center;
  }
  .module-card {
    display: flex;
    align-items: center;
    background-color: #fff;
    padding: 80rpx 50rpx;
    border-radius: 32rpx;
    box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.05);
    margin-bottom: 50rpx;
    transition: all 0.3s ease;
    border: 2rpx solid transparent;
    &:active {
      transform: scale(0.98);
      background-color: #f9f9f9;
    }
  }
  .module-icon {
    width: 140rpx;
    height: 140rpx;
    border-radius: 32rpx;
    display: flex;
    justify-content: center;
    align-items: center;
    margin-right: 40rpx;
    &.qualified {
      background: linear-gradient(135deg, #52c41a, #73d13d);
      box-shadow: 0 10rpx 20rpx rgba(82, 196, 26, 0.2);
    }
    &.unqualified {
      background: linear-gradient(135deg, #ff4d4f, #ff7875);
      box-shadow: 0 10rpx 20rpx rgba(255, 77, 79, 0.2);
    }
  }
  .module-info {
    display: flex;
    flex-direction: column;
  }
  .module-label {
    font-size: 40rpx;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 12rpx;
  }
  .module-desc {
    font-size: 28rpx;
    color: #999;
  }
  .form-content {
  .detail-scroll {
    max-height: calc(100vh - 120rpx);
    box-sizing: border-box;
  }
  .detail-card {
    background-color: #fff;
    margin: 20rpx;
    padding: 30rpx;
    padding: 28rpx;
    border-radius: 16rpx;
    box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
  }
  .limit-tip {
    font-size: 24rpx;
    color: #999;
    margin-left: 20rpx;
  .detail-card-title {
    font-size: 30rpx;
    font-weight: 600;
    color: #333;
    margin-bottom: 20rpx;
    padding-bottom: 16rpx;
    border-bottom: 1rpx solid #eee;
  }
  .detail-card-title--collapsible {
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
  }
  .detail-card-title--collapsible.is-collapsed {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
  }
  .detail-card-title-text {
    flex: 1;
    min-width: 0;
    margin-right: 16rpx;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
  }
  .detail-card-title-no {
    word-break: break-all;
    line-height: 1.4;
  }
  .detail-card-title-seq {
    flex-shrink: 0;
    font-size: 26rpx;
    font-weight: 500;
    color: #888;
    margin-left: 8rpx;
  }
  .detail-card-body {
    padding-top: 4rpx;
  }
  .detail-card-summary {
    padding-top: 8rpx;
  }
  .kv-row--summary {
    padding: 12rpx 0;
    font-size: 26rpx;
  }
  .summary-tip {
    display: block;
    font-size: 24rpx;
    color: #999;
    text-align: center;
    padding: 20rpx 0 8rpx;
  }
  .kv-row {
    display: flex;
    align-items: flex-start;
    padding: 16rpx 0;
    border-bottom: 1rpx solid #f0f0f0;
    font-size: 28rpx;
  }
  .kv-label {
    flex-shrink: 0;
    width: 220rpx;
    color: #888;
    line-height: 1.5;
  }
  .kv-value {
    flex: 1;
    color: #1a1a1a;
    line-height: 1.5;
    word-break: break-all;
    text-align: right;
  }
  .kv-value--tag {
    display: flex;
    justify-content: flex-end;
    align-items: center;
  }
  .stocked-qty-block {
    margin-top: 8rpx;
    padding-top: 8rpx;
    border-top: 1rpx solid #f0f0f0;
  }
  .stocked-qty-row {
    border-bottom: none;
    align-items: center;
  }
  .stocked-qty-input-wrap {
    min-width: 0;
  }
  .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 {
    margin-top: 60rpx;
    display: flex;
    justify-content: space-between;
    padding-bottom: 40rpx;
    align-items: center;
    gap: 24rpx;
    padding: 20rpx 40rpx 60rpx;
  }
  .cancel-btn {
    width: 30%;
  .footer-cancel-btn {
    flex: 1;
    background-color: #f5f5f5;
    color: #666;
    border: none;
  }
  .save-btn {
    width: 65%;
  .footer-confirm-btn {
    flex: 1;
    background: linear-gradient(140deg, #00baff 0%, #006cfb 100%);
    color: #fff;
    border: none;
  }
</style>