云
7 小时以前 7a93a85f75bcfc8001ea1d3b921860806a8209ac
feat(mobile): 新增多个移动端功能模块并优化扫码流程

- 添加生产领料、其他出库、库存移库、盘点任务、销售退货、生产退料等6个移动端功能
- 集成TraceScanInput组件替换原有手动输入方式,实现扫码自动识别
- 新增traceQuantity和findStocks工具函数用于追溯对象数量计算和库存查询
- 实现扫码带出功能,在多个出入库单据中支持扫码自动生成明细
- 移动端路由配置中增加新功能页面映射
- 优化移动端访问权限控制和功能可用性检查逻辑
- 更新物料库存API接口参数,支持批次号、物料编号等查询条件
- 在各明细列表组件中集成TraceScanPanel实现扫码操作面板
已修改18个文件
已添加10个文件
2090 ■■■■■ 文件已修改
src/api/mes/wm/materialstock/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/TraceScanInput.vue 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/TraceScanPanel.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/access.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/api.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/routes.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/traceCode.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/traceLookup.ts 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/miscissue.vue 210 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/productissue.vue 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/returnissue.vue 211 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/returnsales.vue 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/stocktaking.vue 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/transfer.vue 201 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscissue/modules/line-form.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/miscissue/modules/line-list.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productissue/modules/line-form.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productissue/modules/line-list.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productreceipt/modules/line-form.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productreceipt/modules/line-list.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/modules/form.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/modules/line-form.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnissue/modules/line-list.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/modules/line-form.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/modules/line-list.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/stocktaking/task/modules/result-list.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transfer/modules/line-form.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transfer/modules/line-list.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/materialstock/index.ts
@@ -57,8 +57,13 @@
/** åº“存台账分页查询参数 */
export interface MaterialStockPageParams extends PageParam {
  warehouseType?: number; // ä»“库类型:10暂存库,20合格库(按类型过滤仓库)
  batchCode?: string; // æ‰¹æ¬¡å·
  batchId?: number; // æ‰¹æ¬¡ç¼–号
  frozen?: boolean; // æ˜¯å¦å†»ç»“
  itemId?: number; // ç‰©æ–™ç¼–号
  stockState?: number; // åº“存状态:10暂存库,20合格库,30已出库
  virtualFilter?: string; // è™šæ‹Ÿä»“过滤模式:exclude æŽ’除虚拟仓,only åªçœ‹è™šæ‹Ÿä»“
  warehouseType?: number; // ä»“库类型:10暂存库,20合格库(按类型过滤仓库)
}
/** æŸ¥è¯¢åº“存台账分页 */
src/components/TraceScanInput.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Button, Input } from 'ant-design-vue';
import { extractCode } from '#/utils/traceCode';
/**
 * è¿½æº¯äºŒç»´ç æ‰«ç è¾“入。
 *
 * æ”¯æŒæ‘„像头扫码、扫码枪键盘输入和手动输入三种方式;
 * è¯†åˆ«ç»“果统一经过 extractCode æå–「追溯网址?code=码值」中的码值后再抛出。
 */
const props = withDefaults(
  defineProps<{
    continuous?: boolean;
    disabled?: boolean;
    placeholder?: string;
  }>(),
  {
    continuous: false,
    disabled: false,
    placeholder: '扫描追溯二维码或输入追溯码,回车识别',
  },
);
const emit = defineEmits<{ code: [value: string] }>();
const input = ref('');
const error = ref('');
const active = ref(false);
const starting = ref(false);
const video = ref<HTMLVideoElement>();
const inputRef = ref<{ focus: () => void }>();
let stream: MediaStream | undefined;
let frame = 0;
let generation = 0;
let last = 0;
function stop() {
  generation++;
  active.value = false;
  starting.value = false;
  cancelAnimationFrame(frame);
  stream?.getTracks().forEach((track) => track.stop());
  stream = undefined;
  if (video.value) video.value.srcObject = null;
}
function accept(raw: string) {
  if (props.disabled) return;
  try {
    const code = extractCode(raw);
    error.value = '';
    stop();
    input.value = props.continuous ? '' : code;
    emit('code', code);
    if (props.continuous) {
      void nextTick(() => inputRef.value?.focus());
    }
  } catch (e) {
    error.value = e instanceof Error ? e.message : '追溯码格式不正确';
  }
}
watch(
  () => props.continuous,
  async (continuous) => {
    if (!props.disabled && continuous) {
      await nextTick();
      inputRef.value?.focus();
    }
  },
);
async function start() {
  error.value = '';
  stop();
  const ticket = generation;
  starting.value = true;
  try {
    if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
      throw new Error('摄像头需要 HTTPS å’Œç›¸æœºæŽˆæƒï¼Œè¯·ä½¿ç”¨æ‰‹åŠ¨è¾“å…¥');
    }
    const decode = (await import('jsqr')).default;
    const media = await navigator.mediaDevices.getUserMedia({
      audio: false,
      video: { facingMode: { ideal: 'environment' } },
    });
    if (ticket !== generation) {
      media.getTracks().forEach((track) => track.stop());
      return;
    }
    stream = media;
    active.value = true;
    starting.value = false;
    await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
    if (!video.value || ticket !== generation) {
      stop();
      return;
    }
    video.value.srcObject = media;
    await video.value.play();
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d', { willReadFrequently: true });
    function tick(time: number) {
      if (ticket !== generation || !active.value) return;
      const element = video.value;
      if (
        element &&
        ctx &&
        element.readyState >= 2 &&
        time - last > 180 &&
        !props.disabled
      ) {
        last = time;
        canvas.width = Math.min(element.videoWidth, 640);
        canvas.height = Math.round(
          (element.videoHeight * canvas.width) / element.videoWidth,
        );
        ctx.drawImage(element, 0, 0, canvas.width, canvas.height);
        const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
        const result = decode(pixels.data, canvas.width, canvas.height);
        if (result?.data) {
          accept(result.data);
          return;
        }
      }
      frame = requestAnimationFrame(tick);
    }
    frame = requestAnimationFrame(tick);
  } catch (e) {
    if (ticket !== generation) return;
    stop();
    const name = e instanceof DOMException ? e.name : '';
    error.value =
      name === 'NotAllowedError'
        ? '相机权限未开启,请在浏览器设置中允许相机,或手动输入码值'
        : name === 'NotFoundError'
          ? '未找到可用摄像头,请手动输入或使用扫码设备'
          : name === 'NotReadableError'
            ? '摄像头正被其他程序使用,请关闭后重试或手动输入'
            : e instanceof Error
              ? e.message
              : '相机不可用,请检查权限或手动输入';
  }
}
function visibility() {
  if (document.hidden) stop();
}
onMounted(() => document.addEventListener('visibilitychange', visibility));
onBeforeUnmount(() => {
  stop();
  document.removeEventListener('visibilitychange', visibility);
});
</script>
<template>
  <div class="trace-scan-input">
    <div class="flex items-center gap-2">
      <Input
        ref="inputRef"
        v-model:value="input"
        autocomplete="off"
        :disabled="disabled"
        :placeholder="placeholder"
        @press-enter="accept(input)"
      />
      <Button :disabled="disabled || starting" @click="active ? stop() : start()">
        {{ active ? '关闭摄像头' : starting ? '正在请求相机…' : '打开摄像头' }}
      </Button>
      <Button
        type="primary"
        :disabled="disabled || !input.trim()"
        @click="accept(input)"
      >
        è¯†åˆ«
      </Button>
    </div>
    <video
      v-if="active"
      ref="video"
      class="mt-2 w-64 rounded bg-black"
      muted
      playsinline
    />
    <p v-if="error" class="mt-1 text-red-600" role="alert">{{ error }}</p>
  </div>
</template>
src/components/TraceScanPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { TraceResult } from '#/utils/traceLookup';
import { computed, ref } from 'vue';
import { Button } from 'ant-design-vue';
import { lookupTrace } from '#/utils/traceLookup';
import TraceScanInput from './TraceScanInput.vue';
/**
 * è¿½æº¯äºŒç»´ç æ‰«ç æ ¸å¯¹é¢æ¿ã€‚
 *
 * æ‰«ç åŽåªè§£æžå¯¹è±¡å¹¶å±•示核对信息,点击「确认带入」才由页面执行写入。
 */
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    title?: string;
  }>(),
  {
    disabled: false,
    title: '扫码带出',
  },
);
const emit = defineEmits<{ confirm: [trace: TraceResult] }>();
const busy = ref(false);
const error = ref('');
const trace = ref<TraceResult>();
const typeLabel = computed(() =>
  trace.value
    ? ({ bag: '袋码', batch: '批次码', pallet: '托盘码' } as const)[
        trace.value.type
      ]
    : '',
);
const quantity = computed(() => {
  if (!trace.value) return '';
  if (trace.value.type === 'bag') return '1';
  if (trace.value.type === 'pallet') return trace.value.data.bagCount ?? '';
  return trace.value.data.bagQuantity ?? '';
});
async function identify(code: string) {
  if (props.disabled || busy.value) return;
  busy.value = true;
  error.value = '';
  trace.value = undefined;
  try {
    trace.value = await lookupTrace(code);
  } catch (e) {
    error.value = e instanceof Error ? e.message : '追溯码识别失败';
  } finally {
    busy.value = false;
  }
}
function confirm() {
  if (!trace.value) return;
  emit('confirm', trace.value);
  trace.value = undefined;
}
function cancel() {
  trace.value = undefined;
  error.value = '';
}
</script>
<template>
  <section class="mb-3 rounded-lg border border-border p-3">
    <h3 class="mb-2 text-sm font-semibold">{{ title }}</h3>
    <TraceScanInput
      v-if="!trace"
      :disabled="disabled || busy"
      @code="identify"
    />
    <div v-else class="space-y-1 text-sm">
      <p>
        <span class="text-muted-foreground">码类型:</span>{{ typeLabel }}
        <span class="ml-4 text-muted-foreground">码值:</span>{{ trace.code }}
      </p>
      <p>
        <span class="text-muted-foreground">品种:</span
        >{{ trace.data.itemName || '未录入' }}
        <span class="ml-4 text-muted-foreground">批次:</span
        >{{ trace.data.batchCode || '未绑定' }}
      </p>
      <p>
        <span class="text-muted-foreground">可带入数量:</span
        >{{ quantity || '—' }}
      </p>
      <div class="flex gap-2 pt-1">
        <Button type="primary" :disabled="disabled || busy" @click="confirm">
          ç¡®è®¤å¸¦å…¥
        </Button>
        <Button :disabled="busy" @click="cancel">取消本次扫码</Button>
      </div>
    </div>
    <p v-if="error" class="mt-1 text-red-600" role="alert">{{ error }}</p>
  </section>
</template>
src/mobile/access.ts
@@ -8,6 +8,12 @@
  { key: 'quality', title: '种子质检', hint: '袋码、托盘与批次', icon: 'scan', permission: 'mes:qc-seed-quality:submit' },
  { key: 'quality-records', title: '质检记录', hint: '结果与不合格原因', icon: 'records', permission: 'mes:qc-seed-quality:query' },
  { key: 'sales', title: '销售出库', hint: '拣货、运单与出库', icon: 'box', permission: 'mes:wm-product-sales:query' },
  { key: 'productissue', title: '生产领料', hint: '扫码领用物料', icon: 'work', permission: 'mes:wm-product-issue:create' },
  { key: 'miscissue', title: '其他出库', hint: '扫码新增出库明细', icon: 'box', permission: 'mes:wm-misc-issue:create' },
  { key: 'transfer', title: '库存移库', hint: '扫码新增移库明细', icon: 'layers', permission: 'mes:wm-transfer:create' },
  { key: 'stocktaking', title: '盘点任务', hint: '扫码提交盘点数量', icon: 'records', permission: 'mes:wm-stock-taking-task:update' },
  { key: 'returnsales', title: '销售退货', hint: '扫码新增退货明细', icon: 'back', permission: 'mes:wm-return-sales:create' },
  { key: 'returnissue', title: '生产退料', hint: '扫码新增退料明细', icon: 'warehouse', permission: 'mes:wm-return-issue:create' },
] as const;
export function can(permission: string) { return useAccessStore().accessCodes.includes(permission); }
export function availableFeatures() {
@@ -20,14 +26,4 @@
  if (key === 'records') return can('mes:pro-scan-in:query') && useAccessStore().accessMenus.some(m => m.path === '/mobile/inbound');
  return availableFeatures().some(f => f.key === key);
}
export function extractCode(raw: string) {
  let value = raw.trim();
  if (!value) throw new Error('请扫描或输入码值');
  if (/^https?:\/\//i.test(value)) {
    const code = new URL(value).searchParams.get('code');
    if (!code) throw new Error('该链接不包含业务码值');
    value = code.trim();
  }
  if (!value || value.length > 64 || /\s/.test(value)) throw new Error('码值格式不正确');
  return value;
}
export { extractCode } from '#/utils/traceCode';
src/mobile/api.ts
@@ -2,6 +2,7 @@
import { getBagCodeByCode, resolveTraceType } from '#/api/mes/pro/bagCode';
import { getPalletByCode } from '#/api/mes/pro/pallet';
import { getBatchByCode } from '#/api/mes/pro/batch';
import { getMaterialStockPage, type MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { can, extractCode } from './access';
import type { MesProScanEventApi } from '#/api/mes/pro/scanEvent';
export interface TraceObject {
@@ -28,6 +29,27 @@
  if(trace.data.batchStatus===40)return '该对象所属批次已作废,不可执行写操作';
  return '';
}
/** æ‰«ç å¯¹è±¡å¯å¸¦å…¥å•据的数量:袋码按 1 è¢‹ï¼Œæ‰˜ç›˜æŒ‰å·²è£…袋数,批次按批次袋数。 */
export function traceQuantity(trace: TraceResult) {
  if (trace.type === 'bag') return 1;
  if (trace.type === 'pallet') return trace.data.bagCount;
  return trace.data.bagQuantity;
}
/** æŒ‰æ‰«ç å¯¹è±¡çš„物料与批次查找库存记录,供需要选择库存记录的单据带出明细。 */
export async function findStocks(
  trace: TraceResult,
  options: { stockState?: number; virtualFilter?: string } = {},
): Promise<MesWmMaterialStockApi.MaterialStock[]> {
  if (!trace.data.itemId) return [];
  const result = await getMaterialStockPage({
    pageNo: 1,
    pageSize: 50,
    batchCode: trace.data.batchCode,
    itemId: trace.data.itemId,
    ...options,
  });
  return result.list;
}
export function myRecords(params: { pageNo: number; pageSize: number; status?: string; eventId?: string; receivedTime?: string[] }) {
  return requestClient.get<{ list: MesProScanEventApi.ScanEvent[]; total: number }>('/mes/pro/scan-in/my-page', { params });
}
src/mobile/routes.ts
@@ -13,5 +13,11 @@
    { path: 'quality', name: 'MobileQuality', component: () => import('#/views/mobile/quality.vue'), meta: { title: '种子质检' } },
    { path: 'quality-records', name: 'MobileQualityRecords', component: () => import('#/views/mobile/quality-records.vue'), meta: { title: '质检记录' } },
    { path: 'sales', name: 'MobileSales', component: () => import('#/views/mobile/sales.vue'), meta: { title: '销售出库' } },
    { path: 'productissue', name: 'MobileProductIssue', component: () => import('#/views/mobile/productissue.vue'), meta: { title: '生产领料' } },
    { path: 'miscissue', name: 'MobileMiscIssue', component: () => import('#/views/mobile/miscissue.vue'), meta: { title: '其他出库' } },
    { path: 'transfer', name: 'MobileTransfer', component: () => import('#/views/mobile/transfer.vue'), meta: { title: '库存移库' } },
    { path: 'stocktaking', name: 'MobileStocktaking', component: () => import('#/views/mobile/stocktaking.vue'), meta: { title: '盘点任务' } },
    { path: 'returnsales', name: 'MobileReturnSales', component: () => import('#/views/mobile/returnsales.vue'), meta: { title: '销售退货' } },
    { path: 'returnissue', name: 'MobileReturnIssue', component: () => import('#/views/mobile/returnissue.vue'), meta: { title: '生产退料' } },
  ],
}];
src/utils/traceCode.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
/**
 * æå–追溯码。
 *
 * çŽ°åœºæ ‡ç­¾å°çš„æ˜¯è¿½æº¯äºŒç»´ç ï¼Œå†…å®¹ä¸ºã€Œè¿½æº¯ç½‘å€?code=追溯码」;
 * æ‰«ç æžªä¹Ÿå¯èƒ½æŠŠæ•´æ®µç½‘址直接提交,所有扫码入口都应先经过这里规范化。
 * åŽç«¯ MesTraceCodeUtils ä¿æŒåŒä¸€å£å¾„。
 */
export function extractCode(raw: string): string {
  let value = raw.trim();
  if (!value) throw new Error('请扫描或输入追溯码');
  if (/^https?:\/\//i.test(value)) {
    let code: string | null;
    try {
      code = new URL(value).searchParams.get('code');
    } catch {
      throw new Error('追溯网址格式不正确');
    }
    if (!code) throw new Error('该追溯网址不包含追溯码');
    value = code.trim();
  }
  if (!value || value.length > 64 || /\s/.test(value)) {
    throw new Error('追溯码格式不正确');
  }
  return value;
}
src/utils/traceLookup.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { getBagCodeByCode, resolveTraceType } from '#/api/mes/pro/bagCode';
import { getBatchByCode } from '#/api/mes/pro/batch';
import { getPalletByCode } from '#/api/mes/pro/pallet';
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import { extractCode } from './traceCode';
export type TraceType = 'bag' | 'batch' | 'pallet';
export interface TraceObject {
  bagCount?: number;
  bagQuantity?: number;
  batchCode?: string;
  batchId?: number;
  batchStatus?: number;
  businessCode?: string;
  code?: string;
  id?: number;
  inboundBagQuantity?: number;
  inboundTime?: string;
  itemId?: number;
  itemName?: string;
  lineName?: string;
  palletCode?: string;
  palletId?: number;
  pendingBagQuantity?: number;
  produceDate?: string;
  status?: number;
  tempCode?: string;
  unitMeasureName?: string;
}
export interface TraceResult {
  code: string;
  data: TraceObject;
  type: TraceType;
}
/** è§£æžæ‰«æåŽŸæ–‡ï¼ŒæŒ‰ç ç±»åž‹æŸ¥è¯¢è¢‹ç  / æ‰˜ç›˜ / æ‰¹æ¬¡å¯¹è±¡ã€‚ */
export async function lookupTrace(raw: string): Promise<TraceResult> {
  const code = extractCode(raw);
  const type = (await resolveTraceType(code)) as TraceType | null;
  if (!type) {
    throw new Error('未找到对应袋码、托盘或批次');
  }
  const fetchers: Record<TraceType, (value: string) => Promise<TraceObject>> = {
    bag: getBagCodeByCode,
    batch: getBatchByCode,
    pallet: getPalletByCode,
  };
  const data = await fetchers[type](code);
  if (!data?.id) {
    throw new Error('未找到对象信息');
  }
  return { code, data, type };
}
/** æ‰«ç å¯¹è±¡å¯å¸¦å…¥å•据的数量:袋码按 1 è¢‹ï¼Œæ‰˜ç›˜æŒ‰å·²è£…袋数,批次按批次袋数。 */
export function traceQuantity(trace: TraceResult): number | undefined {
  if (trace.type === 'bag') {
    return 1;
  }
  if (trace.type === 'pallet') {
    return trace.data.bagCount;
  }
  return trace.data.bagQuantity;
}
/** æŒ‰æ‰«ç å¯¹è±¡çš„物料与批次查找对应库存记录,供需要选择库存记录的单据带出明细。 */
export async function findMaterialStocks(
  trace: TraceResult,
  options: { stockState?: number; virtualFilter?: string } = {},
): Promise<MesWmMaterialStockApi.MaterialStock[]> {
  if (!trace.data.itemId) {
    return [];
  }
  const result = await getMaterialStockPage({
    pageNo: 1,
    pageSize: 50,
    batchCode: trace.data.batchCode,
    itemId: trace.data.itemId,
    ...options,
  });
  return result.list;
}
src/views/mobile/miscissue.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
  getMiscIssue,
  getMiscIssuePage,
  type MesWmMiscIssueApi,
} from '#/api/mes/wm/miscissue';
import {
  createMiscIssueLine,
  getMiscIssueLinePage,
  type MesWmMiscIssueLineApi,
} from '#/api/mes/wm/miscissue/line';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import {
  errorText,
  findStocks,
  lookup,
  traceQuantity,
  writeBlockReason,
  type TraceResult,
} from '#/mobile/api';
import Scanner from './scanner.vue';
defineOptions({ name: 'MobileMiscIssue' });
type Issue = MesWmMiscIssueApi.MiscIssue;
type Line = MesWmMiscIssueLineApi.MiscIssueLine;
const busy = ref(false);
const error = ref('');
const notice = ref('');
const code = ref('');
const rows = ref<Issue[]>([]);
const page = ref(0);
const total = ref(0);
const issue = ref<Issue>();
const lines = ref<Line[]>([]);
const trace = ref<TraceResult>();
const stock = ref<MesWmMaterialStockApi.MaterialStock>();
const quantity = ref(1);
let filters: { code?: string } = {};
async function load(reset = true) {
  if (busy.value) return;
  busy.value = true;
  error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined } : filters;
  try {
    const data = await getMiscIssuePage({ ...query, status: 0, pageNo: next, pageSize: 20 });
    rows.value = reset ? data.list : [...rows.value, ...data.list];
    page.value = next;
    total.value = data.total;
    filters = query;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function readLines(issueId: number) {
  const all: Line[] = [];
  for (let n = 1; ; n++) {
    const data = await getMiscIssueLinePage({ issueId, pageNo: n, pageSize: 100 });
    all.push(...data.list);
    if (all.length >= data.total || !data.list.length) break;
  }
  return all;
}
async function open(id?: number) {
  if (!id || busy.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  stock.value = undefined;
  try {
    const detail = await getMiscIssue(id);
    if (!detail) throw new Error('出库单不存在或已被删除');
    issue.value = detail;
    lines.value = await readLines(id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function identify(raw: string) {
  if (busy.value || !issue.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  stock.value = undefined;
  try {
    const found = await lookup(raw);
    const reason = writeBlockReason(found);
    if (reason) throw new Error(reason);
    const stocks = await findStocks(found);
    const matched = stocks.find((item) => (item.quantity ?? 0) > 0) ?? stocks[0];
    if (!matched) throw new Error('未找到该追溯码对应的库存记录');
    trace.value = found;
    stock.value = matched;
    const max = traceQuantity(found);
    quantity.value = max && matched.quantity ? Math.min(max, matched.quantity) : (max ?? matched.quantity ?? 1);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function confirm() {
  const current = issue.value;
  const matched = stock.value;
  if (!current?.id || !matched?.id || busy.value) return;
  if (!Number.isFinite(quantity.value) || quantity.value <= 0) {
    error.value = '出库数量必须大于零';
    return;
  }
  if (!window.confirm(`确认出库 ${matched.batchCode || ''} ${quantity.value}?`)) return;
  busy.value = true;
  error.value = '';
  try {
    await createMiscIssueLine({
      issueId: current.id,
      itemId: matched.itemId,
      materialStockId: matched.id,
      quantity: quantity.value,
      batchCode: matched.batchCode,
      batchId: matched.batchId,
      warehouseId: matched.warehouseId,
      locationId: matched.locationId,
      areaId: matched.areaId,
      remark: `扫码来源 ${trace.value?.code ?? ''}`,
    });
    notice.value = '已添加出库明细。';
    trace.value = undefined;
    stock.value = undefined;
    lines.value = await readLines(current.id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
onMounted(() => load());
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{ notice }}</p>
  <p v-if="error" class="m-error" role="alert">{{ error }}</p>
  <template v-if="issue">
    <button class="m-secondary" :disabled="busy" @click="issue = undefined; trace = undefined; stock = undefined; load()">
      è¿”回出库单列表
    </button>
    <section class="m-panel" style="margin-top: 16px">
      <h2 class="m-title">{{ issue.code }}</h2>
      <dl>
        <div class="m-row"><dt>名称</dt><dd>{{ issue.name || '—' }}</dd></div>
        <div class="m-row"><dt>备注</dt><dd>{{ issue.remark || '—' }}</dd></div>
      </dl>
    </section>
    <h2 class="m-section-title">出库明细</h2>
    <p v-if="!lines.length" class="m-empty">该单暂无出库明细</p>
    <article v-for="line in lines" :key="line.id" class="m-panel">
      <h3 class="m-title">{{ line.itemName || line.itemCode }}</h3>
      <p>出库数量 {{ line.quantity ?? 0 }}</p>
      <p class="m-muted">批次 {{ line.batchCode || '未录入' }}</p>
    </article>
    <h2 class="m-section-title">扫码出库</h2>
    <p class="m-muted">扫描袋上追溯二维码,按物料与批次匹配库存后新增一行出库明细。</p>
    <Scanner v-if="!trace" :disabled="busy" @code="identify" />
    <section v-else class="m-panel">
      <h3 class="m-title">核对出库对象</h3>
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ trace.data.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ stock?.batchCode || trace.data.batchCode || '未绑定' }}</dd></div>
        <div class="m-row"><dt>库存数量</dt><dd>{{ stock?.quantity ?? '—' }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
      <label class="m-field">
        <span>出库数量</span>
        <input v-model.number="quantity" type="number" inputmode="decimal" min="0.01" step="any" :disabled="busy" />
      </label>
      <div class="m-action-bar">
        <button class="m-primary" :disabled="busy" @click="confirm">确认添加明细</button>
        <button class="m-secondary" :disabled="busy" @click="trace = undefined; stock = undefined">重新扫描</button>
      </div>
    </section>
  </template>
  <template v-else>
    <form class="m-panel" @submit.prevent="load()">
      <label class="m-field"><span>出库单号</span><input v-model="code" :disabled="busy" placeholder="输入出库单号" /></label>
      <button class="m-secondary" :disabled="busy">{{ busy ? '正在查询…' : '查询草稿出库单' }}</button>
    </form>
    <p class="m-muted" role="status">共 {{ total }} å¼ è‰ç¨¿å‡ºåº“单</p>
    <p v-if="!rows.length && !busy && !error" class="m-empty">暂无草稿出库单</p>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)">
      <strong>{{ row.code }}</strong><span>草稿</span><small>{{ row.name || '未录入名称' }}</small>
    </button>
    <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">
      {{ busy ? '正在加载…' : '加载更多' }}
    </button>
  </template>
</template>
src/views/mobile/productissue.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
  getProductIssue,
  getProductIssuePage,
  type MesWmProductIssueApi,
} from '#/api/mes/wm/productissue';
import {
  createProductIssueLine,
  getProductIssueLinePage,
  type MesWmProductIssueLineApi,
} from '#/api/mes/wm/productissue/line';
import {
  errorText,
  lookup,
  traceQuantity,
  writeBlockReason,
  type TraceResult,
} from '#/mobile/api';
import Scanner from './scanner.vue';
defineOptions({ name: 'MobileProductIssue' });
type Issue = MesWmProductIssueApi.ProductIssue;
type Line = MesWmProductIssueLineApi.ProductIssueLine;
const busy = ref(false);
const error = ref('');
const notice = ref('');
const code = ref('');
const rows = ref<Issue[]>([]);
const page = ref(0);
const total = ref(0);
const issue = ref<Issue>();
const lines = ref<Line[]>([]);
const trace = ref<TraceResult>();
const quantity = ref(1);
let filters: { code?: string } = {};
function formatTime(value?: number) {
  return value ? new Date(value).toLocaleString() : '—';
}
async function load(reset = true) {
  if (busy.value) return;
  busy.value = true;
  error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined } : filters;
  try {
    const data = await getProductIssuePage({
      ...query,
      status: 0,
      pageNo: next,
      pageSize: 20,
    });
    rows.value = reset ? data.list : [...rows.value, ...data.list];
    page.value = next;
    total.value = data.total;
    filters = query;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function readLines(issueId: number) {
  const all: Line[] = [];
  for (let n = 1; ; n++) {
    const data = await getProductIssueLinePage({
      issueId,
      pageNo: n,
      pageSize: 100,
    });
    all.push(...data.list);
    if (all.length >= data.total || !data.list.length) break;
  }
  return all;
}
async function open(id?: number) {
  if (!id || busy.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  try {
    const detail = await getProductIssue(id);
    if (!detail) throw new Error('领料单不存在或已被删除');
    issue.value = detail;
    lines.value = await readLines(id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function identify(raw: string) {
  if (busy.value || !issue.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  try {
    const found = await lookup(raw);
    const reason = writeBlockReason(found);
    if (reason) throw new Error(reason);
    if (!found.data.itemId) throw new Error('该追溯码未绑定物料');
    trace.value = found;
    quantity.value = traceQuantity(found) ?? 1;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function confirm() {
  const current = issue.value;
  const found = trace.value;
  if (!current?.id || !found?.data.itemId || busy.value) return;
  if (!Number.isFinite(quantity.value) || quantity.value <= 0) {
    error.value = '领料数量必须大于零';
    return;
  }
  if (!window.confirm(`确认按追溯码添加领料明细:${found.data.itemName || ''} ${quantity.value}?`)) return;
  busy.value = true;
  error.value = '';
  try {
    await createProductIssueLine({
      issueId: current.id,
      itemId: found.data.itemId,
      quantity: quantity.value,
      remark: `扫码来源 ${found.code}`,
    });
    notice.value = '已添加领料明细。';
    trace.value = undefined;
    lines.value = await readLines(current.id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
onMounted(() => load());
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{ notice }}</p>
  <p v-if="error" class="m-error" role="alert">{{ error }}</p>
  <template v-if="issue">
    <button class="m-secondary" :disabled="busy" @click="issue = undefined; trace = undefined; load()">
      è¿”回领料单列表
    </button>
    <section class="m-panel" style="margin-top: 16px">
      <h2 class="m-title">{{ issue.code }}</h2>
      <dl>
        <div class="m-row"><dt>需求时间</dt><dd>{{ formatTime(issue.requiredTime) }}</dd></div>
        <div class="m-row"><dt>备注</dt><dd>{{ issue.remark || '—' }}</dd></div>
      </dl>
    </section>
    <h2 class="m-section-title">领料明细</h2>
    <p v-if="!lines.length" class="m-empty">该单暂无领料明细</p>
    <article v-for="line in lines" :key="line.id" class="m-panel">
      <h3 class="m-title">{{ line.itemName || line.itemCode }}</h3>
      <p>领料数量 {{ line.quantity ?? 0 }}</p>
      <p v-if="line.remark" class="m-muted" style="overflow-wrap: anywhere">{{ line.remark }}</p>
    </article>
    <h2 class="m-section-title">扫码领料</h2>
    <p class="m-muted">扫描袋上追溯二维码,自动带出物料与数量,确认后新增一行明细。</p>
    <Scanner v-if="!trace" :disabled="busy" @code="identify" />
    <section v-else class="m-panel">
      <h3 class="m-title">核对领料对象</h3>
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ trace.data.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ trace.data.batchCode || '未绑定' }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
      <label class="m-field">
        <span>领料数量</span>
        <input v-model.number="quantity" type="number" inputmode="decimal" min="0.01" step="any" :disabled="busy" />
      </label>
      <div class="m-action-bar">
        <button class="m-primary" :disabled="busy" @click="confirm">确认添加明细</button>
        <button class="m-secondary" :disabled="busy" @click="trace = undefined">重新扫描</button>
      </div>
    </section>
  </template>
  <template v-else>
    <form class="m-panel" @submit.prevent="load()">
      <label class="m-field"><span>领料单号</span><input v-model="code" :disabled="busy" placeholder="输入领料单号" /></label>
      <button class="m-secondary" :disabled="busy">{{ busy ? '正在查询…' : '查询草稿领料单' }}</button>
    </form>
    <p class="m-muted" role="status">共 {{ total }} å¼ è‰ç¨¿é¢†æ–™å•</p>
    <p v-if="!rows.length && !busy && !error" class="m-empty">暂无草稿领料单</p>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)">
      <strong>{{ row.code }}</strong><span>草稿</span><small>{{ row.remark || '未录入备注' }}</small>
    </button>
    <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">
      {{ busy ? '正在加载…' : '加载更多' }}
    </button>
  </template>
</template>
src/views/mobile/returnissue.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import {
  getReturnIssue,
  getReturnIssuePage,
  type MesWmReturnIssueApi,
} from '#/api/mes/wm/returnissue';
import {
  createReturnIssueLine,
  getReturnIssueLinePage,
  type MesWmReturnIssueLineApi,
} from '#/api/mes/wm/returnissue/line';
import {
  errorText,
  findStocks,
  lookup,
  traceQuantity,
  writeBlockReason,
  type TraceResult,
} from '#/mobile/api';
import Scanner from './scanner.vue';
defineOptions({ name: 'MobileReturnIssue' });
type Issue = MesWmReturnIssueApi.ReturnIssue;
type Line = MesWmReturnIssueLineApi.ReturnIssueLine;
const busy = ref(false);
const error = ref('');
const notice = ref('');
const code = ref('');
const rows = ref<Issue[]>([]);
const page = ref(0);
const total = ref(0);
const issue = ref<Issue>();
const lines = ref<Line[]>([]);
const trace = ref<TraceResult>();
const stock = ref<MesWmMaterialStockApi.MaterialStock>();
const quantity = ref(1);
let filters: { code?: string } = {};
async function load(reset = true) {
  if (busy.value) return;
  busy.value = true;
  error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined } : filters;
  try {
    const data = await getReturnIssuePage({ ...query, status: 0, pageNo: next, pageSize: 20 });
    rows.value = reset ? data.list : [...rows.value, ...data.list];
    page.value = next;
    total.value = data.total;
    filters = query;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function readLines(issueId: number) {
  const all: Line[] = [];
  for (let n = 1; ; n++) {
    const data = await getReturnIssueLinePage({ issueId, pageNo: n, pageSize: 100 });
    all.push(...data.list);
    if (all.length >= data.total || !data.list.length) break;
  }
  return all;
}
async function open(id?: number) {
  if (!id || busy.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  stock.value = undefined;
  try {
    const detail = await getReturnIssue(id);
    if (!detail) throw new Error('生产退料单不存在或已被删除');
    issue.value = detail;
    lines.value = await readLines(id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function identify(raw: string) {
  if (busy.value || !issue.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  stock.value = undefined;
  try {
    const found = await lookup(raw);
    const reason = writeBlockReason(found);
    if (reason) throw new Error(reason);
    const stocks = await findStocks(found, { virtualFilter: 'only' });
    const matched = stocks.find((item) => (item.quantity ?? 0) > 0) ?? stocks[0];
    if (!matched) throw new Error('未找到该追溯码对应的线边库库存记录');
    trace.value = found;
    stock.value = matched;
    const max = traceQuantity(found);
    quantity.value = max && matched.quantity ? Math.min(max, matched.quantity) : (max ?? matched.quantity ?? 1);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function confirm() {
  const current = issue.value;
  const matched = stock.value;
  if (!current?.id || !matched?.id || busy.value) return;
  if (!Number.isFinite(quantity.value) || quantity.value <= 0) {
    error.value = '退料数量必须大于零';
    return;
  }
  if (!window.confirm(`确认退料 ${matched.batchCode || ''} ${quantity.value}?`)) return;
  busy.value = true;
  error.value = '';
  try {
    await createReturnIssueLine({
      issueId: current.id,
      itemId: matched.itemId,
      materialStockId: matched.id,
      quantity: quantity.value,
      batchId: matched.batchId,
      batchCode: matched.batchCode,
      rqcCheckFlag: false,
      remark: `扫码来源 ${trace.value?.code ?? ''}`,
    });
    notice.value = '已添加退料明细。';
    trace.value = undefined;
    stock.value = undefined;
    lines.value = await readLines(current.id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
onMounted(() => load());
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{ notice }}</p>
  <p v-if="error" class="m-error" role="alert">{{ error }}</p>
  <template v-if="issue">
    <button class="m-secondary" :disabled="busy" @click="issue = undefined; trace = undefined; stock = undefined; load()">
      è¿”回生产退料单列表
    </button>
    <section class="m-panel" style="margin-top: 16px">
      <h2 class="m-title">{{ issue.code }}</h2>
      <dl>
        <div class="m-row"><dt>名称</dt><dd>{{ issue.name || '—' }}</dd></div>
        <div class="m-row"><dt>生产任务</dt><dd>{{ issue.taskCode || '—' }}</dd></div>
        <div class="m-row"><dt>工作站</dt><dd>{{ issue.workstationName || '—' }}</dd></div>
        <div class="m-row"><dt>备注</dt><dd>{{ issue.remark || '—' }}</dd></div>
      </dl>
    </section>
    <h2 class="m-section-title">退料明细</h2>
    <p v-if="!lines.length" class="m-empty">该单暂无退料明细</p>
    <article v-for="line in lines" :key="line.id" class="m-panel">
      <h3 class="m-title">{{ line.itemName || line.itemCode }}</h3>
      <p>退料数量 {{ line.quantity ?? 0 }}</p>
      <p class="m-muted">批次 {{ line.batchCode || '未录入' }}</p>
    </article>
    <h2 class="m-section-title">扫码退料</h2>
    <p class="m-muted">扫描袋上追溯二维码,按物料与批次匹配线边库库存后新增一行退料明细。</p>
    <Scanner v-if="!trace" :disabled="busy" @code="identify" />
    <section v-else class="m-panel">
      <h3 class="m-title">核对退料对象</h3>
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ stock?.itemName || trace.data.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ stock?.batchCode || trace.data.batchCode || '未绑定' }}</dd></div>
        <div class="m-row"><dt>库位</dt><dd>{{ [stock?.warehouseName, stock?.locationName, stock?.areaName].filter(Boolean).join(' / ') || '—' }}</dd></div>
        <div class="m-row"><dt>库存数量</dt><dd>{{ stock?.quantity ?? '—' }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
      <label class="m-field">
        <span>退料数量</span>
        <input v-model.number="quantity" type="number" inputmode="decimal" min="0.01" step="any" :disabled="busy" />
      </label>
      <div class="m-action-bar">
        <button class="m-primary" :disabled="busy" @click="confirm">确认添加明细</button>
        <button class="m-secondary" :disabled="busy" @click="trace = undefined; stock = undefined">重新扫描</button>
      </div>
    </section>
  </template>
  <template v-else>
    <form class="m-panel" @submit.prevent="load()">
      <label class="m-field"><span>退料单号</span><input v-model="code" :disabled="busy" placeholder="输入退料单号" /></label>
      <button class="m-secondary" :disabled="busy">{{ busy ? '正在查询…' : '查询草稿退料单' }}</button>
    </form>
    <p class="m-muted" role="status">共 {{ total }} å¼ è‰ç¨¿é€€æ–™å•</p>
    <p v-if="!rows.length && !busy && !error" class="m-empty">暂无草稿退料单</p>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)">
      <strong>{{ row.code }}</strong><span>草稿</span><small>{{ row.taskName || '未关联任务' }}</small>
    </button>
    <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">
      {{ busy ? '正在加载…' : '加载更多' }}
    </button>
  </template>
</template>
src/views/mobile/returnsales.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { getBatchPage } from '#/api/mes/wm/batch';
import {
  getReturnSales,
  getReturnSalesPage,
  getReturnableProductSalesLineList,
  type MesWmReturnSalesApi,
} from '#/api/mes/wm/returnsales';
import {
  createReturnSalesLine,
  getReturnSalesLinePage,
  type MesWmReturnSalesLineApi,
} from '#/api/mes/wm/returnsales/line';
import {
  errorText,
  lookup,
  traceQuantity,
  writeBlockReason,
  type TraceResult,
} from '#/mobile/api';
import Scanner from './scanner.vue';
defineOptions({ name: 'MobileReturnSales' });
type Order = MesWmReturnSalesApi.ReturnSales;
type Line = MesWmReturnSalesLineApi.ReturnSalesLine;
const busy = ref(false);
const error = ref('');
const notice = ref('');
const code = ref('');
const rows = ref<Order[]>([]);
const page = ref(0);
const total = ref(0);
const order = ref<Order>();
const lines = ref<Line[]>([]);
const trace = ref<TraceResult>();
const quantity = ref(1);
const productSalesLineId = ref<number>();
const batchId = ref<number>();
const returnable = ref(0);
let filters: { code?: string } = {};
function resetScan() {
  trace.value = undefined;
  productSalesLineId.value = undefined;
  batchId.value = undefined;
  returnable.value = 0;
}
async function load(reset = true) {
  if (busy.value) return;
  busy.value = true;
  error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined } : filters;
  try {
    const data = await getReturnSalesPage({ ...query, status: 0, pageNo: next, pageSize: 20 });
    rows.value = reset ? data.list : [...rows.value, ...data.list];
    page.value = next;
    total.value = data.total;
    filters = query;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function readLines(returnId: number) {
  const all: Line[] = [];
  for (let n = 1; ; n++) {
    const data = await getReturnSalesLinePage({ returnId, pageNo: n, pageSize: 100 });
    all.push(...data.list);
    if (all.length >= data.total || !data.list.length) break;
  }
  return all;
}
async function open(id?: number) {
  if (!id || busy.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  resetScan();
  try {
    const detail = await getReturnSales(id);
    if (!detail) throw new Error('销售退货单不存在或已被删除');
    order.value = detail;
    lines.value = await readLines(id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function identify(raw: string) {
  const current = order.value;
  if (busy.value || !current?.id) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  resetScan();
  try {
    const found = await lookup(raw);
    const reason = writeBlockReason(found);
    if (reason) throw new Error(reason);
    const { batchCode } = found.data;
    const itemId = found.data.itemId;
    if (!itemId || !batchCode) throw new Error('该追溯码未绑定物料或批次');
    if (current.productSalesId) {
      const list = await getReturnableProductSalesLineList(current.productSalesId);
      const matched = list.find((item) => item.itemId === itemId && item.batchCode === batchCode);
      if (!matched) throw new Error('该追溯码对应的批次不在本单可退范围内');
      const max = Number(matched.returnableQuantity ?? 0);
      returnable.value = max;
      productSalesLineId.value = matched.productSalesLineId;
      batchId.value = matched.batchId;
      const wanted = traceQuantity(found) ?? max;
      quantity.value = Math.min(wanted, max);
      trace.value = found;
      return;
    }
    const batches = await getBatchPage({ code: batchCode, itemId, pageNo: 1, pageSize: 20 });
    const batch = batches.list.find((item) => item.code === batchCode);
    if (!batch?.id) throw new Error('未找到该追溯码对应的入库批次');
    batchId.value = batch.id;
    quantity.value = traceQuantity(found) ?? 1;
    trace.value = found;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function confirm() {
  const current = order.value;
  const found = trace.value;
  if (!current?.id || !found?.data.itemId || busy.value) return;
  if (!Number.isFinite(quantity.value) || quantity.value <= 0) {
    error.value = '退货数量必须大于零';
    return;
  }
  if (!window.confirm(`确认退货 ${found.data.itemName || ''} ${quantity.value}?`)) return;
  busy.value = true;
  error.value = '';
  try {
    await createReturnSalesLine({
      returnId: current.id,
      productSalesLineId: productSalesLineId.value,
      itemId: found.data.itemId,
      batchId: batchId.value,
      batchCode: found.data.batchCode,
      quantity: quantity.value,
      remark: `扫码来源 ${found.code}`,
    });
    notice.value = '已添加退货明细。';
    resetScan();
    lines.value = await readLines(current.id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
onMounted(() => load());
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{ notice }}</p>
  <p v-if="error" class="m-error" role="alert">{{ error }}</p>
  <template v-if="order">
    <button class="m-secondary" :disabled="busy" @click="order = undefined; resetScan(); load()">
      è¿”回销售退货单列表
    </button>
    <section class="m-panel" style="margin-top: 16px">
      <h2 class="m-title">{{ order.code }}</h2>
      <dl>
        <div class="m-row"><dt>客户</dt><dd>{{ order.clientName || order.clientCode || '—' }}</dd></div>
        <div class="m-row"><dt>销售出库单</dt><dd>{{ order.productSalesCode || '未关联' }}</dd></div>
        <div class="m-row"><dt>退货原因</dt><dd>{{ order.returnReason || '—' }}</dd></div>
        <div class="m-row"><dt>备注</dt><dd>{{ order.remark || '—' }}</dd></div>
      </dl>
    </section>
    <h2 class="m-section-title">退货明细</h2>
    <p v-if="!lines.length" class="m-empty">该单暂无退货明细</p>
    <article v-for="line in lines" :key="line.id" class="m-panel">
      <h3 class="m-title">{{ line.itemName || line.itemCode }}</h3>
      <p>退货数量 {{ line.quantity ?? 0 }}</p>
      <p class="m-muted">批次 {{ line.batchCode || '未录入' }}</p>
    </article>
    <h2 class="m-section-title">扫码退货</h2>
    <p class="m-muted">
      {{ order.productSalesId ? '扫描追溯二维码,匹配本单销售出库单的可退行后新增退货明细。' : '扫描追溯二维码,按物料与批次匹配入库批次后新增退货明细。' }}
    </p>
    <Scanner v-if="!trace" :disabled="busy" @code="identify" />
    <section v-else class="m-panel">
      <h3 class="m-title">核对退货对象</h3>
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ trace.data.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ trace.data.batchCode || '未绑定' }}</dd></div>
        <div class="m-row" v-if="returnable"><dt>可退数量</dt><dd>{{ returnable }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
      <label class="m-field">
        <span>退货数量</span>
        <input v-model.number="quantity" type="number" inputmode="decimal" min="0.01" step="any" :disabled="busy" />
      </label>
      <div class="m-action-bar">
        <button class="m-primary" :disabled="busy" @click="confirm">确认添加明细</button>
        <button class="m-secondary" :disabled="busy" @click="resetScan">重新扫描</button>
      </div>
    </section>
  </template>
  <template v-else>
    <form class="m-panel" @submit.prevent="load()">
      <label class="m-field"><span>退货单号</span><input v-model="code" :disabled="busy" placeholder="输入退货单号" /></label>
      <button class="m-secondary" :disabled="busy">{{ busy ? '正在查询…' : '查询草稿退货单' }}</button>
    </form>
    <p class="m-muted" role="status">共 {{ total }} å¼ è‰ç¨¿é€€è´§å•</p>
    <p v-if="!rows.length && !busy && !error" class="m-empty">暂无草稿退货单</p>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)">
      <strong>{{ row.code }}</strong><span>草稿</span><small>{{ row.clientName || '未关联客户' }}</small>
    </button>
    <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">
      {{ busy ? '正在加载…' : '加载更多' }}
    </button>
  </template>
</template>
src/views/mobile/stocktaking.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
  getStockTaking,
  getStockTakingPage,
  scanStockTaking,
  type MesWmStockTakingTaskApi,
} from '#/api/mes/wm/stocktaking/task';
import {
  getStockTakingTaskLineSimpleList,
  type MesWmStockTakingTaskLineApi,
} from '#/api/mes/wm/stocktaking/task/line';
import {
  errorText,
  lookup,
  traceQuantity,
  writeBlockReason,
  type TraceResult,
} from '#/mobile/api';
import Scanner from './scanner.vue';
defineOptions({ name: 'MobileStocktaking' });
type Task = MesWmStockTakingTaskApi.StockTakingTask;
type Line = MesWmStockTakingTaskLineApi.StockTakingTaskLine;
const busy = ref(false);
const error = ref('');
const notice = ref('');
const code = ref('');
const rows = ref<Task[]>([]);
const page = ref(0);
const total = ref(0);
const task = ref<Task>();
const lines = ref<Line[]>([]);
const trace = ref<TraceResult>();
const matchedLine = ref<Line>();
const quantity = ref(1);
let filters: { code?: string } = {};
async function load(reset = true) {
  if (busy.value) return;
  busy.value = true;
  error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined } : filters;
  try {
    const data = await getStockTakingPage({ ...query, status: 2, pageNo: next, pageSize: 20 });
    rows.value = reset ? data.list : [...rows.value, ...data.list];
    page.value = next;
    total.value = data.total;
    filters = query;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function readLines(taskId: number) {
  return getStockTakingTaskLineSimpleList(taskId);
}
async function open(id?: number) {
  if (!id || busy.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  matchedLine.value = undefined;
  try {
    const detail = await getStockTaking(id);
    if (!detail) throw new Error('盘点任务不存在或已被删除');
    task.value = detail;
    lines.value = await readLines(id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function identify(raw: string) {
  if (busy.value || !task.value?.id) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  matchedLine.value = undefined;
  try {
    const found = await lookup(raw);
    const reason = writeBlockReason(found);
    if (reason) throw new Error(reason);
    const line = lines.value.find((item) => item.batchCode && item.batchCode === found.data.batchCode);
    trace.value = found;
    matchedLine.value = line;
    const max = traceQuantity(found);
    quantity.value = line?.quantity ?? max ?? 1;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function confirm() {
  const current = task.value;
  const found = trace.value;
  if (!current?.id || !found || busy.value) return;
  if (!Number.isFinite(quantity.value) || quantity.value < 0) {
    error.value = '盘点数量不能小于零';
    return;
  }
  if (!window.confirm(`确认盘点 ${found.data.itemName || ''} ${quantity.value}?`)) return;
  busy.value = true;
  error.value = '';
  try {
    await scanStockTaking({
      taskId: current.id,
      scanCode: found.code,
      takingQuantity: quantity.value,
      remark: '移动端扫码盘点',
    });
    notice.value = '已提交盘点结果。';
    trace.value = undefined;
    matchedLine.value = undefined;
    lines.value = await readLines(current.id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
onMounted(() => load());
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{ notice }}</p>
  <p v-if="error" class="m-error" role="alert">{{ error }}</p>
  <template v-if="task">
    <button class="m-secondary" :disabled="busy" @click="task = undefined; trace = undefined; matchedLine = undefined; load()">
      è¿”回盘点任务列表
    </button>
    <section class="m-panel" style="margin-top: 16px">
      <h2 class="m-title">{{ task.code }}</h2>
      <dl>
        <div class="m-row"><dt>名称</dt><dd>{{ task.name || '—' }}</dd></div>
        <div class="m-row"><dt>盘点日期</dt><dd>{{ task.takingDate || '—' }}</dd></div>
        <div class="m-row"><dt>盘点人</dt><dd>{{ task.userNickname || '—' }}</dd></div>
        <div class="m-row"><dt>盘点方案</dt><dd>{{ task.planName || '—' }}</dd></div>
        <div class="m-row"><dt>备注</dt><dd>{{ task.remark || '—' }}</dd></div>
      </dl>
    </section>
    <h2 class="m-section-title">盘点范围</h2>
    <p v-if="!lines.length" class="m-empty">该任务暂无盘点行</p>
    <article v-for="line in lines" :key="line.id" class="m-panel">
      <h3 class="m-title">{{ line.itemName || line.itemCode }}</h3>
      <p>账面数量 {{ line.quantity ?? 0 }} <span class="m-muted">批次 {{ line.batchCode || '未录入' }}</span></p>
      <p class="m-muted">{{ [line.warehouseName, line.locationName, line.areaName].filter(Boolean).join(' / ') || '—' }}</p>
      <p v-if="line.takingQuantity != null" class="m-muted">已盘 {{ line.takingQuantity }},差异 {{ line.differenceQuantity ?? 0 }}</p>
    </article>
    <h2 class="m-section-title">扫码盘点</h2>
    <p class="m-muted">扫描袋上追溯二维码或批次码,按批次匹配盘点行后提交盘点数量。</p>
    <Scanner v-if="!trace" :disabled="busy" @code="identify" />
    <section v-else class="m-panel">
      <h3 class="m-title">核对盘点对象</h3>
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ trace.data.itemName || matchedLine?.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ trace.data.batchCode || matchedLine?.batchCode || '未绑定' }}</dd></div>
        <div class="m-row"><dt>库位</dt><dd>{{ [matchedLine?.warehouseName, matchedLine?.locationName, matchedLine?.areaName].filter(Boolean).join(' / ') || '不在盘点范围' }}</dd></div>
        <div class="m-row"><dt>账面数量</dt><dd>{{ matchedLine?.quantity ?? '—' }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
      <label class="m-field">
        <span>盘点数量</span>
        <input v-model.number="quantity" type="number" inputmode="decimal" min="0" step="any" :disabled="busy" />
      </label>
      <div class="m-action-bar">
        <button class="m-primary" :disabled="busy" @click="confirm">确认提交盘点</button>
        <button class="m-secondary" :disabled="busy" @click="trace = undefined; matchedLine = undefined">重新扫描</button>
      </div>
    </section>
  </template>
  <template v-else>
    <form class="m-panel" @submit.prevent="load()">
      <label class="m-field"><span>盘点任务号</span><input v-model="code" :disabled="busy" placeholder="输入盘点任务号" /></label>
      <button class="m-secondary" :disabled="busy">{{ busy ? '正在查询…' : '查询盘点中任务' }}</button>
    </form>
    <p class="m-muted" role="status">共 {{ total }} ä¸ªç›˜ç‚¹ä¸­ä»»åŠ¡</p>
    <p v-if="!rows.length && !busy && !error" class="m-empty">暂无盘点中任务</p>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)">
      <strong>{{ row.code }}</strong><span>盘点中</span><small>{{ row.name || '未录入名称' }}</small>
    </button>
    <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">
      {{ busy ? '正在加载…' : '加载更多' }}
    </button>
  </template>
</template>
src/views/mobile/transfer.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import {
  getTransfer,
  getTransferPage,
  type MesWmTransferApi,
} from '#/api/mes/wm/transfer';
import {
  createTransferLine,
  getTransferLineList,
  type MesWmTransferLineApi,
} from '#/api/mes/wm/transfer/line';
import {
  errorText,
  findStocks,
  lookup,
  traceQuantity,
  writeBlockReason,
  type TraceResult,
} from '#/mobile/api';
import Scanner from './scanner.vue';
defineOptions({ name: 'MobileTransfer' });
type Transfer = MesWmTransferApi.Transfer;
type Line = MesWmTransferLineApi.TransferLine;
const busy = ref(false);
const error = ref('');
const notice = ref('');
const code = ref('');
const rows = ref<Transfer[]>([]);
const page = ref(0);
const total = ref(0);
const transfer = ref<Transfer>();
const lines = ref<Line[]>([]);
const trace = ref<TraceResult>();
const stock = ref<MesWmMaterialStockApi.MaterialStock>();
const quantity = ref(1);
let filters: { code?: string } = {};
async function load(reset = true) {
  if (busy.value) return;
  busy.value = true;
  error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined } : filters;
  try {
    const data = await getTransferPage({ ...query, status: 0, pageNo: next, pageSize: 20 });
    rows.value = reset ? data.list : [...rows.value, ...data.list];
    page.value = next;
    total.value = data.total;
    filters = query;
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function open(id?: number) {
  if (!id || busy.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  stock.value = undefined;
  try {
    const detail = await getTransfer(id);
    if (!detail) throw new Error('转移单不存在或已被删除');
    transfer.value = detail;
    lines.value = await getTransferLineList(id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function identify(raw: string) {
  if (busy.value || !transfer.value) return;
  busy.value = true;
  error.value = '';
  notice.value = '';
  trace.value = undefined;
  stock.value = undefined;
  try {
    const found = await lookup(raw);
    const reason = writeBlockReason(found);
    if (reason) throw new Error(reason);
    const stocks = await findStocks(found);
    const matched = stocks.find((item) => (item.quantity ?? 0) > 0) ?? stocks[0];
    if (!matched) throw new Error('未找到该追溯码对应的库存记录');
    trace.value = found;
    stock.value = matched;
    const max = traceQuantity(found);
    quantity.value = max && matched.quantity ? Math.min(max, matched.quantity) : (max ?? matched.quantity ?? 1);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
async function confirm() {
  const current = transfer.value;
  const matched = stock.value;
  if (!current?.id || !matched?.id || busy.value) return;
  if (!Number.isFinite(quantity.value) || quantity.value <= 0) {
    error.value = '转移数量必须大于零';
    return;
  }
  if (!window.confirm(`确认移库 ${matched.batchCode || ''} ${quantity.value}?`)) return;
  busy.value = true;
  error.value = '';
  try {
    await createTransferLine({
      transferId: current.id,
      materialStockId: matched.id,
      itemId: matched.itemId,
      quantity: quantity.value,
      batchId: matched.batchId,
      fromWarehouseId: matched.warehouseId,
      fromLocationId: matched.locationId,
      fromAreaId: matched.areaId,
      remark: `扫码来源 ${trace.value?.code ?? ''}`,
    });
    notice.value = '已添加移库明细。';
    trace.value = undefined;
    stock.value = undefined;
    lines.value = await getTransferLineList(current.id);
  } catch (e) {
    error.value = errorText(e);
  } finally {
    busy.value = false;
  }
}
onMounted(() => load());
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{ notice }}</p>
  <p v-if="error" class="m-error" role="alert">{{ error }}</p>
  <template v-if="transfer">
    <button class="m-secondary" :disabled="busy" @click="transfer = undefined; trace = undefined; stock = undefined; load()">
      è¿”回转移单列表
    </button>
    <section class="m-panel" style="margin-top: 16px">
      <h2 class="m-title">{{ transfer.code }}</h2>
      <dl>
        <div class="m-row"><dt>名称</dt><dd>{{ transfer.name || '—' }}</dd></div>
        <div class="m-row"><dt>目的地</dt><dd>{{ transfer.destinationAddress || '—' }}</dd></div>
        <div class="m-row"><dt>备注</dt><dd>{{ transfer.remark || '—' }}</dd></div>
      </dl>
    </section>
    <h2 class="m-section-title">移库明细</h2>
    <p v-if="!lines.length" class="m-empty">该单暂无移库明细</p>
    <article v-for="line in lines" :key="line.id" class="m-panel">
      <h3 class="m-title">{{ line.itemName || line.itemCode }}</h3>
      <p>转移数量 {{ line.quantity ?? 0 }}</p>
      <p class="m-muted">批次 {{ line.batchCode || '未录入' }}</p>
    </article>
    <h2 class="m-section-title">扫码移库</h2>
    <p class="m-muted">扫描袋上追溯二维码,按物料与批次匹配库存后新增一行移库明细。</p>
    <Scanner v-if="!trace" :disabled="busy" @code="identify" />
    <section v-else class="m-panel">
      <h3 class="m-title">核对移库对象</h3>
      <dl>
        <div class="m-row"><dt>物料</dt><dd>{{ trace.data.itemName || '未录入' }}</dd></div>
        <div class="m-row"><dt>批次</dt><dd>{{ stock?.batchCode || trace.data.batchCode || '未绑定' }}</dd></div>
        <div class="m-row"><dt>移出位置</dt><dd>{{ [stock?.warehouseName, stock?.locationName, stock?.areaName].filter(Boolean).join(' / ') || '—' }}</dd></div>
        <div class="m-row"><dt>库存数量</dt><dd>{{ stock?.quantity ?? '—' }}</dd></div>
        <div class="m-row"><dt>码值</dt><dd style="overflow-wrap: anywhere">{{ trace.code }}</dd></div>
      </dl>
      <label class="m-field">
        <span>转移数量</span>
        <input v-model.number="quantity" type="number" inputmode="decimal" min="0.01" step="any" :disabled="busy" />
      </label>
      <div class="m-action-bar">
        <button class="m-primary" :disabled="busy" @click="confirm">确认添加明细</button>
        <button class="m-secondary" :disabled="busy" @click="trace = undefined; stock = undefined">重新扫描</button>
      </div>
    </section>
  </template>
  <template v-else>
    <form class="m-panel" @submit.prevent="load()">
      <label class="m-field"><span>转移单号</span><input v-model="code" :disabled="busy" placeholder="输入转移单号" /></label>
      <button class="m-secondary" :disabled="busy">{{ busy ? '正在查询…' : '查询草稿转移单' }}</button>
    </form>
    <p class="m-muted" role="status">共 {{ total }} å¼ è‰ç¨¿è½¬ç§»å•</p>
    <p v-if="!rows.length && !busy && !error" class="m-empty">暂无草稿转移单</p>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)">
      <strong>{{ row.code }}</strong><span>草稿</span><small>{{ row.name || '未录入名称' }}</small>
    </button>
    <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">
      {{ busy ? '正在加载…' : '加载更多' }}
    </button>
  </template>
</template>
src/views/wls/miscissue/modules/line-form.vue
@@ -72,11 +72,15 @@
    formApi.setState({ schema: useLineFormSchema(formApi) });
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      defaults?: Partial<MesWmMiscIssueLineApi.MiscIssueLine>;
      id?: number;
      issueId: number;
    }>();
    issueId.value = data.issueId;
    if (!data.id) {
      if (data.defaults) {
        await formApi.setValues(data.defaults);
      }
      return;
    }
    modalApi.lock();
src/views/wls/miscissue/modules/line-list.vue
@@ -4,6 +4,8 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMiscIssueLineApi } from '#/api/mes/wm/miscissue/line';
import type { TraceResult } from '#/utils/traceLookup';
import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -15,7 +17,9 @@
  deleteMiscIssueLine,
  getMiscIssueLinePage,
} from '#/api/mes/wm/miscissue/line';
import TraceScanPanel from '#/components/TraceScanPanel.vue';
import { $t } from '#/locales';
import { findMaterialStocks, traceQuantity } from '#/utils/traceLookup';
import { useLineGridColumns } from '../data';
import LineForm from './line-form.vue';
@@ -43,6 +47,39 @@
/** æ·»åŠ ç‰©æ–™ */
function handleCreate() {
  lineFormModalApi.setData({ issueId: props.issueId }).open();
}
/** æ‰«ç å¸¦å‡ºï¼šæŒ‰è¿½æº¯ç åŒ¹é…åº“存记录后打开新增行表单 */
async function handleTraceConfirm(trace: TraceResult) {
  try {
    const stocks = await findMaterialStocks(trace);
    const stock = stocks.find((item) => (item.quantity ?? 0) > 0) ?? stocks[0];
    if (!stock) {
      message.warning('未找到该追溯码对应的库存记录');
      return;
    }
    const quantity = traceQuantity(trace);
    lineFormModalApi
      .setData({
        defaults: {
          areaId: stock.areaId,
          batchCode: stock.batchCode,
          itemId: stock.itemId,
          locationId: stock.locationId,
          materialStockId: stock.id,
          quantity:
            quantity && stock.quantity
              ? Math.min(quantity, stock.quantity)
              : (quantity ?? stock.quantity),
          quantityMax: stock.quantity,
          warehouseId: stock.warehouseId,
        },
        issueId: props.issueId,
      })
      .open();
  } catch (e) {
    message.error(e instanceof Error ? e.message : '扫码带出失败');
  }
}
/** ç¼–辑物料 */
@@ -98,6 +135,11 @@
<template>
  <div>
    <LineFormModal @success="handleRefresh" />
    <TraceScanPanel
      v-if="isEditable"
      title="扫码带出出库明细"
      @confirm="handleTraceConfirm"
    />
    <Grid table-title="物料信息">
      <template v-if="isEditable" #toolbar-tools>
        <TableAction
src/views/wls/productissue/modules/line-form.vue
@@ -70,9 +70,16 @@
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ id?: number; issueId: number }>();
    const data = modalApi.getData<{
      defaults?: Partial<MesWmProductIssueLineApi.ProductIssueLine>;
      id?: number;
      issueId: number;
    }>();
    issueId.value = data.issueId;
    if (!data.id) {
      if (data.defaults) {
        await formApi.setValues(data.defaults);
      }
      return;
    }
    modalApi.lock();
src/views/wls/productissue/modules/line-list.vue
@@ -5,6 +5,8 @@
import type { MesWmProductIssueDetailApi } from '#/api/mes/wm/productissue/detail';
import type { MesWmProductIssueLineApi } from '#/api/mes/wm/productissue/line';
import type { TraceResult } from '#/utils/traceLookup';
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -17,7 +19,9 @@
  deleteProductIssueLine,
  getProductIssueLinePage,
} from '#/api/mes/wm/productissue/line';
import TraceScanPanel from '#/components/TraceScanPanel.vue';
import { $t } from '#/locales';
import { traceQuantity } from '#/utils/traceLookup';
import { useLineGridColumns } from '../data';
import DetailForm from './detail-form.vue';
@@ -59,6 +63,23 @@
/** æ·»åŠ ç‰©æ–™ */
function handleCreate() {
  lineFormModalApi.setData({ issueId: props.issueId }).open();
}
/** æ‰«ç å¸¦å‡ºï¼šæŒ‰è¿½æº¯ç å¸¦å‡ºç‰©æ–™ä¸Žæ•°é‡ */
function handleTraceConfirm(trace: TraceResult) {
  if (!trace.data.itemId) {
    message.warning('该追溯码未绑定物料');
    return;
  }
  lineFormModalApi
    .setData({
      defaults: {
        itemId: trace.data.itemId,
        quantity: traceQuantity(trace),
      },
      issueId: props.issueId,
    })
    .open();
}
/** ç¼–辑物料 */
@@ -167,6 +188,11 @@
  <div>
    <LineFormModal @success="handleRefresh" />
    <DetailFormModal @success="handleDetailSuccess" />
    <TraceScanPanel
      v-if="isEditable"
      title="扫码带出领料明细"
      @confirm="handleTraceConfirm"
    />
    <Grid table-title="物料信息">
      <template v-if="isEditable" #toolbar-tools>
        <TableAction
src/views/wls/productreceipt/modules/line-form.vue
@@ -71,9 +71,16 @@
    }
    formApi.setState({ schema: useLineFormSchema(formApi) });
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ id?: number; receiptId: number }>();
    const data = modalApi.getData<{
      defaults?: Partial<MesWmProductReceiptLineApi.ProductReceiptLine>;
      id?: number;
      receiptId: number;
    }>();
    receiptId.value = data.receiptId;
    if (!data.id) {
      if (data.defaults) {
        await formApi.setValues(data.defaults);
      }
      return;
    }
    modalApi.lock();
src/views/wls/productreceipt/modules/line-list.vue
@@ -5,6 +5,8 @@
import type { MesWmProductReceiptDetailApi } from '#/api/mes/wm/productreceipt/detail';
import type { MesWmProductReceiptLineApi } from '#/api/mes/wm/productreceipt/line';
import type { TraceResult } from '#/utils/traceLookup';
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -18,7 +20,9 @@
  deleteProductReceiptLine,
  getProductReceiptLinePage,
} from '#/api/mes/wm/productreceipt/line';
import TraceScanPanel from '#/components/TraceScanPanel.vue';
import { $t } from '#/locales';
import { findMaterialStocks, traceQuantity } from '#/utils/traceLookup';
import { BarcodeDetail, PrinterLabel } from '#/views/wls/barcode/components';
import { useLineGridColumns } from '../data';
@@ -62,6 +66,37 @@
/** æ·»åŠ ç‰©æ–™ */
function handleCreate() {
  lineFormModalApi.setData({ receiptId: props.receiptId }).open();
}
/** æ‰«ç å¸¦å‡ºï¼šæŒ‰è¿½æº¯ç åŒ¹é…åº“存记录后打开新增行表单 */
async function handleTraceConfirm(trace: TraceResult) {
  try {
    const stocks = await findMaterialStocks(trace, { virtualFilter: 'only' });
    const stock = stocks[0];
    if (!stock) {
      message.warning('未找到该追溯码对应的库存记录');
      return;
    }
    const quantity = traceQuantity(trace);
    lineFormModalApi
      .setData({
        defaults: {
          batchCode: stock.batchCode,
          batchId: stock.batchId,
          itemId: stock.itemId,
          materialStockId: stock.id,
          quantity:
            quantity && stock.quantity
              ? Math.min(quantity, stock.quantity)
              : (quantity ?? stock.quantity),
          quantityMax: stock.quantity,
        },
        receiptId: props.receiptId,
      })
      .open();
  } catch (e) {
    message.error(e instanceof Error ? e.message : '扫码带出失败');
  }
}
/** ç¼–辑物料 */
@@ -184,6 +219,11 @@
  <div>
    <LineFormModal @success="handleRefresh" />
    <DetailFormModal @success="handleDetailSuccess" />
    <TraceScanPanel
      v-if="isEditable"
      title="扫码带出物料明细"
      @confirm="handleTraceConfirm"
    />
    <Grid table-title="物料信息">
      <template v-if="isEditable" #toolbar-tools>
        <TableAction
src/views/wls/productsales/modules/form.vue
@@ -23,6 +23,7 @@
  submitProductSales,
  updateProductSales,
} from '#/api/mes/wm/productsales';
import TraceScanInput from '#/components/TraceScanInput.vue';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
@@ -33,7 +34,7 @@
const formType = ref<FormType>('create');
const formData = ref<MesWmProductSalesApi.ProductSales>();
const scanContent = ref('');
const scanType = ref<'BAG' | 'PALLET' | 'BATCH'>('BAG');
const scanType = ref<'' | 'BAG' | 'BATCH' | 'PALLET'>('');
const scanPreview = ref<MesWmProductSalesApi.ProductSalesScanResponse>();
const selectedBagIds = ref<number[]>([]);
const scanLoading = ref(false);
@@ -108,16 +109,18 @@
  }
}
/** å…ˆè¯†åˆ«å¯¹è±¡å¹¶é€‰æ‹©è¢‹ç ï¼Œå›žè½¦ä¸ä¼šç›´æŽ¥å†™å…¥æ‹£è´§ã€‚ */
async function handleScan() {
  if (!formData.value?.id || !scanContent.value.trim() || scanLoading.value) {
/** å…ˆè¯†åˆ«å¯¹è±¡å¹¶é€‰æ‹©è¢‹ç ï¼Œç¡®è®¤åŽæ‰å†™å…¥æ‹£è´§ã€‚ */
async function handleScan(raw?: string) {
  const content = (raw ?? scanContent.value).trim();
  if (!formData.value?.id || !content || scanLoading.value) {
    return;
  }
  scanContent.value = content;
  scanLoading.value = true;
  try {
    scanPreview.value = undefined;
    selectedBagIds.value = [];
    const result = await previewProductSalesScan(scanContent.value.trim(), scanType.value, formData.value.id);
    const result = await previewProductSalesScan(content, scanType.value || undefined, formData.value.id);
    scanPreview.value = result;
    if (result.scanType === 'BAG') selectedBagIds.value = result.bags?.filter(b => b.selectable).map(b => b.id) || [];
    if (!result.scanQuantity) message.warning('当前对象没有可拣袋码,请核对质检和库存状态');
@@ -291,19 +294,14 @@
        <div v-if="isStock" class="mb-3 flex items-center gap-2 px-4">
          <label for="sales-picking-type" class="sr-only">拣货扫码类型</label>
          <a-select id="sales-picking-type" v-model:value="scanType" :disabled="scanLoading" style="width:140px" @change="scanPreview = undefined; selectedBagIds = []">
            <a-select-option value="BAG">袋码</a-select-option><a-select-option value="PALLET">托盘码</a-select-option><a-select-option value="BATCH">生产批次码</a-select-option>
            <a-select-option value="">自动识别</a-select-option><a-select-option value="BAG">袋码</a-select-option><a-select-option value="PALLET">托盘码</a-select-option><a-select-option value="BATCH">生产批次码</a-select-option>
          </a-select>
          <a-input
            v-model:value="scanContent"
            :disabled="scanLoading"
            autofocus
          <TraceScanInput
            class="flex-1"
            placeholder="扫描所选类型码,回车识别"
            @press-enter="handleScan"
            :disabled="scanLoading"
            placeholder="扫描追溯二维码或输入追溯码,回车识别"
            @code="handleScan"
          />
          <Button :loading="scanLoading" type="default" @click="handleScan">
            è¯†åˆ«ç å€¼
          </Button>
        </div>
        <section v-if="isStock && scanPreview" class="mb-4 rounded-lg border border-border p-4">
          <h3 class="font-semibold">核对拣货对象</h3><p>批次 {{scanPreview.batchCode}} Â· å¯æ‹£ {{scanPreview.scanQuantity}} è¢‹</p>
src/views/wls/returnissue/modules/line-form.vue
@@ -71,9 +71,16 @@
    }
    formApi.setState({ schema: useLineFormSchema(formApi) });
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ id?: number; issueId: number }>();
    const data = modalApi.getData<{
      defaults?: Partial<MesWmReturnIssueLineApi.ReturnIssueLine>;
      id?: number;
      issueId: number;
    }>();
    issueId.value = data.issueId;
    if (!data.id) {
      if (data.defaults) {
        await formApi.setValues(data.defaults);
      }
      return;
    }
    modalApi.lock();
src/views/wls/returnissue/modules/line-list.vue
@@ -5,6 +5,8 @@
import type { MesWmReturnIssueDetailApi } from '#/api/mes/wm/returnissue/detail';
import type { MesWmReturnIssueLineApi } from '#/api/mes/wm/returnissue/line';
import type { TraceResult } from '#/utils/traceLookup';
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -18,7 +20,9 @@
  deleteReturnIssueLine,
  getReturnIssueLinePage,
} from '#/api/mes/wm/returnissue/line';
import TraceScanPanel from '#/components/TraceScanPanel.vue';
import { $t } from '#/locales';
import { findMaterialStocks, traceQuantity } from '#/utils/traceLookup';
import { BarcodeDetail, PrinterLabel } from '#/views/wls/barcode/components';
import { useLineGridColumns } from '../data';
@@ -62,6 +66,40 @@
/** æ·»åŠ ç‰©æ–™ */
function handleCreate() {
  lineFormModalApi.setData({ issueId: props.issueId }).open();
}
/** æ‰«ç å¸¦å‡ºï¼šæŒ‰è¿½æº¯ç åŒ¹é…çº¿è¾¹åº“库存记录后打开新增退料行 */
async function handleTraceConfirm(trace: TraceResult) {
  try {
    const stocks = await findMaterialStocks(trace, { virtualFilter: 'only' });
    const stock = stocks.find((item) => (item.quantity ?? 0) > 0) ?? stocks[0];
    if (!stock) {
      message.warning('未找到该追溯码对应的线边库库存记录');
      return;
    }
    const quantity = traceQuantity(trace);
    lineFormModalApi
      .setData({
        defaults: {
          batchCode: stock.batchCode,
          batchId: stock.batchId,
          itemId: stock.itemId,
          materialDisplayName: [stock.itemName, stock.specification]
            .filter(Boolean)
            .join(' | '),
          materialStockId: stock.id,
          quantity:
            quantity && stock.quantity
              ? Math.min(quantity, stock.quantity)
              : (quantity ?? stock.quantity),
          quantityMax: stock.quantity,
        },
        issueId: props.issueId,
      })
      .open();
  } catch (e) {
    message.error(e instanceof Error ? e.message : '扫码带出失败');
  }
}
/** ç¼–辑物料 */
@@ -180,6 +218,11 @@
  <div>
    <LineFormModal @success="handleRefresh" />
    <DetailFormModal @success="handleDetailSuccess" />
    <TraceScanPanel
      v-if="isEditable"
      title="扫码带出退料明细"
      @confirm="handleTraceConfirm"
    />
    <Grid table-title="物料信息">
      <template v-if="isEditable" #toolbar-tools>
        <TableAction
src/views/wls/returnsales/modules/line-form.vue
@@ -107,6 +107,7 @@
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      clientId?: number;
      defaults?: Partial<MesWmReturnSalesLineApi.ReturnSalesLine>;
      id?: number;
      productSalesId?: number;
      returnId: number;
@@ -120,6 +121,9 @@
      ),
    });
    if (!data.id) {
      if (data.defaults) {
        await formApi.setValues(data.defaults);
      }
      return;
    }
    modalApi.lock();
src/views/wls/returnsales/modules/line-list.vue
@@ -5,6 +5,8 @@
import type { MesWmReturnSalesDetailApi } from '#/api/mes/wm/returnsales/detail';
import type { MesWmReturnSalesLineApi } from '#/api/mes/wm/returnsales/line';
import type { TraceResult } from '#/utils/traceLookup';
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -13,11 +15,15 @@
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReturnSalesDetailListByLineId } from '#/api/mes/wm/returnsales/detail';
import { getBatchPage } from '#/api/mes/wm/batch';
import { getReturnableProductSalesLineList } from '#/api/mes/wm/returnsales';
import {
  deleteReturnSalesLine,
  getReturnSalesLinePage,
} from '#/api/mes/wm/returnsales/line';
import TraceScanPanel from '#/components/TraceScanPanel.vue';
import { $t } from '#/locales';
import { traceQuantity } from '#/utils/traceLookup';
import { PrinterLabel } from '#/views/wls/barcode/components';
import { useLineGridColumns } from '../data';
@@ -68,6 +74,72 @@
      returnId: props.returnId,
    })
    .open();
}
/** æ‰«ç å¸¦å‡ºï¼šå…³è”出库单时匹配可退出库行,否则按批次带出物料与数量 */
async function handleTraceConfirm(trace: TraceResult) {
  const { batchCode, itemId } = trace.data;
  if (!itemId || !batchCode) {
    message.warning('该追溯码未绑定物料或批次');
    return;
  }
  try {
    if (props.productSalesId) {
      const lines = await getReturnableProductSalesLineList(
        props.productSalesId,
      );
      const matched = lines.find(
        (line) => line.itemId === itemId && line.batchCode === batchCode,
      );
      if (!matched) {
        message.warning('该追溯码对应的批次不在本单可退范围内');
        return;
      }
      const returnable = Number(matched.returnableQuantity ?? 0);
      const quantity = Math.min(traceQuantity(trace) ?? returnable, returnable);
      lineFormModalApi
        .setData({
          clientId: props.clientId,
          defaults: {
            batchCode: matched.batchCode,
            itemId: matched.itemId,
            productSalesLineId: matched.productSalesLineId,
            quantity,
            returnableQuantity: returnable,
          },
          productSalesId: props.productSalesId,
          returnId: props.returnId,
        })
        .open();
      return;
    }
    const batches = await getBatchPage({
      code: batchCode,
      itemId,
      pageNo: 1,
      pageSize: 20,
    });
    const batch = batches.list.find((item) => item.code === batchCode);
    if (!batch?.id) {
      message.warning('未找到该追溯码对应的入库批次');
      return;
    }
    lineFormModalApi
      .setData({
        clientId: props.clientId,
        defaults: {
          batchCode,
          batchId: batch.id,
          itemId,
          quantity: traceQuantity(trace),
        },
        productSalesId: props.productSalesId,
        returnId: props.returnId,
      })
      .open();
  } catch (e) {
    message.error(e instanceof Error ? e.message : '扫码带出失败');
  }
}
/** ç¼–辑物料 */
@@ -188,6 +260,11 @@
  <div>
    <LineFormModal @success="handleRefresh" />
    <DetailFormModal @success="handleDetailSuccess" />
    <TraceScanPanel
      v-if="isEditable"
      title="扫码带出退货明细"
      @confirm="handleTraceConfirm"
    />
    <Grid table-title="物料信息">
      <template v-if="isEditable" #toolbar-tools>
        <TableAction
src/views/wls/stocktaking/task/modules/result-list.vue
@@ -6,7 +6,7 @@
import { useVbenModal } from '@vben/common-ui';
import { Button, Input, InputNumber, message } from 'ant-design-vue';
import { Button, InputNumber, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { scanStockTaking } from '#/api/mes/wm/stocktaking/task';
@@ -14,6 +14,7 @@
  deleteStockTakingResult,
  getStockTakingResultPage,
} from '#/api/mes/wm/stocktaking/task/result';
import TraceScanInput from '#/components/TraceScanInput.vue';
import { $t } from '#/locales';
import { useResultGridColumns } from '../data';
@@ -32,11 +33,13 @@
const scanning = ref(false);
/** æ‰«ç ç›˜ç‚¹ */
async function handleScan() {
  if (!scanCode.value) {
async function handleScan(raw?: string) {
  const code = (raw ?? scanCode.value).trim();
  if (!code) {
    message.warning('请输入追溯码/袋码/托盘码/批次号');
    return;
  }
  scanCode.value = code;
  if (takingQuantity.value === undefined || takingQuantity.value === null) {
    message.warning('请输入盘点数量');
    return;
@@ -133,12 +136,11 @@
      class="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-dashed border-yellow-300 bg-yellow-50 px-3 py-2"
    >
      <span class="font-medium text-yellow-700">PDA æ‰«ç ç›˜ç‚¹ï¼š</span>
      <Input
        v-model:value="scanCode"
        allow-clear
        class="!w-56"
        placeholder="追溯码/袋码/托盘码/批次号"
        @press-enter="handleScan"
      <TraceScanInput
        class="!w-72"
        :disabled="scanning"
        placeholder="扫描追溯二维码或输入追溯码,回车识别"
        @code="handleScan"
      />
      <InputNumber
        v-model:value="takingQuantity"
@@ -146,12 +148,12 @@
        :precision="2"
        class="!w-32"
        placeholder="盘点数量"
        @press-enter="handleScan"
        @press-enter="() => handleScan()"
      />
      <Button
        type="primary"
        :loading="scanning"
        @click="handleScan"
        @click="() => handleScan()"
      >
        æ‰«ç ç›˜ç‚¹
      </Button>
src/views/wls/transfer/modules/line-form.vue
@@ -72,9 +72,16 @@
    }
    formApi.setState({ schema: useLineFormSchema(formApi) });
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ id?: number; transferId: number }>();
    const data = modalApi.getData<{
      defaults?: Partial<MesWmTransferLineApi.TransferLine>;
      id?: number;
      transferId: number;
    }>();
    transferId.value = data.transferId;
    if (!data.id) {
      if (data.defaults) {
        await formApi.setValues(data.defaults);
      }
      return;
    }
    modalApi.lock();
src/views/wls/transfer/modules/line-list.vue
@@ -5,6 +5,8 @@
import type { MesWmTransferDetailApi } from '#/api/mes/wm/transfer/detail';
import type { MesWmTransferLineApi } from '#/api/mes/wm/transfer/line';
import type { TraceResult } from '#/utils/traceLookup';
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -17,7 +19,9 @@
  deleteTransferLine,
  getTransferLineList,
} from '#/api/mes/wm/transfer/line';
import TraceScanPanel from '#/components/TraceScanPanel.vue';
import { $t } from '#/locales';
import { findMaterialStocks, traceQuantity } from '#/utils/traceLookup';
import { PrinterLabel } from '#/views/wls/barcode/components';
import { useLineGridColumns } from '../data';
@@ -59,6 +63,40 @@
/** æ·»åŠ è°ƒæ‹¨ç‰©æ–™ */
function handleCreate() {
  lineFormModalApi.setData({ transferId: props.transferId }).open();
}
/** æ‰«ç å¸¦å‡ºï¼šæŒ‰è¿½æº¯ç åŒ¹é…åº“存记录后打开新增调拨行 */
async function handleTraceConfirm(trace: TraceResult) {
  try {
    const stocks = await findMaterialStocks(trace);
    const stock = stocks.find((item) => (item.quantity ?? 0) > 0) ?? stocks[0];
    if (!stock) {
      message.warning('未找到该追溯码对应的库存记录');
      return;
    }
    const quantity = traceQuantity(trace);
    lineFormModalApi
      .setData({
        defaults: {
          batchCode: stock.batchCode,
          batchId: stock.batchId,
          fromAreaId: stock.areaId,
          fromLocationId: stock.locationId,
          fromWarehouseId: stock.warehouseId,
          itemId: stock.itemId,
          materialStockId: stock.id,
          quantity:
            quantity && stock.quantity
              ? Math.min(quantity, stock.quantity)
              : (quantity ?? stock.quantity),
          quantityMax: stock.quantity,
        },
        transferId: props.transferId,
      })
      .open();
  } catch (e) {
    message.error(e instanceof Error ? e.message : '扫码带出失败');
  }
}
/** ç¼–辑调拨物料 */
@@ -166,6 +204,11 @@
  <div>
    <LineFormModal @success="handleRefresh" />
    <DetailFormModal @success="handleDetailSuccess" />
    <TraceScanPanel
      v-if="isEditable"
      title="扫码带出调拨明细"
      @confirm="handleTraceConfirm"
    />
    <Grid table-title="物料信息">
      <template v-if="isEditable" #toolbar-tools>
        <TableAction