From 7a93a85f75bcfc8001ea1d3b921860806a8209ac Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期四, 29 十月 2026 12:28:48 +0800
Subject: [PATCH] feat(mobile): 新增多个移动端功能模块并优化扫码流程

---
 src/views/wls/transfer/modules/line-form.vue           |    9 
 src/mobile/access.ts                                   |   18 
 src/mobile/routes.ts                                   |    6 
 src/views/wls/productsales/modules/form.vue            |   28 
 src/views/mobile/returnissue.vue                       |  211 ++++++
 src/views/wls/productreceipt/modules/line-form.vue     |    9 
 src/views/wls/stocktaking/task/modules/result-list.vue |   24 
 src/components/TraceScanPanel.vue                      |  107 +++
 src/views/wls/miscissue/modules/line-form.vue          |    4 
 src/views/wls/returnissue/modules/line-form.vue        |    9 
 src/views/wls/returnsales/modules/line-list.vue        |   77 ++
 src/mobile/api.ts                                      |   22 
 src/views/wls/productreceipt/modules/line-list.vue     |   40 +
 src/utils/traceCode.ts                                 |   25 
 src/views/wls/transfer/modules/line-list.vue           |   43 +
 src/views/mobile/returnsales.vue                       |  233 ++++++
 src/views/mobile/stocktaking.vue                       |  199 +++++
 src/views/mobile/miscissue.vue                         |  210 ++++++
 src/views/wls/productissue/modules/line-list.vue       |   26 
 src/views/mobile/productissue.vue                      |  206 +++++
 src/utils/traceLookup.ts                               |   87 ++
 src/views/wls/returnissue/modules/line-list.vue        |   43 +
 src/views/wls/productissue/modules/line-form.vue       |    9 
 src/api/mes/wm/materialstock/index.ts                  |    7 
 src/components/TraceScanInput.vue                      |  191 +++++
 src/views/wls/returnsales/modules/line-form.vue        |    4 
 src/views/wls/miscissue/modules/line-list.vue          |   42 +
 src/views/mobile/transfer.vue                          |  201 +++++
 28 files changed, 2,048 insertions(+), 42 deletions(-)

diff --git a/src/api/mes/wm/materialstock/index.ts b/src/api/mes/wm/materialstock/index.ts
index 43315a5..c4c5784 100644
--- a/src/api/mes/wm/materialstock/index.ts
+++ b/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鍚堟牸搴擄紙鎸夌被鍨嬭繃婊や粨搴擄級
 }
 
 /** 鏌ヨ搴撳瓨鍙拌处鍒嗛〉 */
diff --git a/src/components/TraceScanInput.vue b/src/components/TraceScanInput.vue
new file mode 100644
index 0000000..4eddf5c
--- /dev/null
+++ b/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 : '杩芥函鐮佹牸寮忎笉姝g‘';
+  }
+}
+
+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 ? '姝e湪璇锋眰鐩告満鈥�' : '鎵撳紑鎽勫儚澶�' }}
+      </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>
diff --git a/src/components/TraceScanPanel.vue b/src/components/TraceScanPanel.vue
new file mode 100644
index 0000000..81d5eac
--- /dev/null
+++ b/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';
+
+/**
+ * 杩芥函浜岀淮鐮佹壂鐮佹牳瀵归潰鏉裤��
+ *
+ * 鎵爜鍚庡彧瑙f瀽瀵硅薄骞跺睍绀烘牳瀵逛俊鎭紝鐐瑰嚮銆岀‘璁ゅ甫鍏ャ�嶆墠鐢遍〉闈㈡墽琛屽啓鍏ャ��
+ */
+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>
diff --git a/src/mobile/access.ts b/src/mobile/access.ts
index c5868a5..55f1f85 100644
--- a/src/mobile/access.ts
+++ b/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: '鎷h揣銆佽繍鍗曚笌鍑哄簱', 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('鐮佸�兼牸寮忎笉姝g‘');
-  return value;
-}
+export { extractCode } from '#/utils/traceCode';
diff --git a/src/mobile/api.ts b/src/mobile/api.ts
index 20422b7..30cec36 100644
--- a/src/mobile/api.ts
+++ b/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 });
 }
diff --git a/src/mobile/routes.ts b/src/mobile/routes.ts
index e533b17..89d0eb0 100644
--- a/src/mobile/routes.ts
+++ b/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: '鐢熶骇閫�鏂�' } },
   ],
 }];
diff --git a/src/utils/traceCode.ts b/src/utils/traceCode.ts
new file mode 100644
index 0000000..e633a3e
--- /dev/null
+++ b/src/utils/traceCode.ts
@@ -0,0 +1,25 @@
+/**
+ * 鎻愬彇杩芥函鐮併��
+ *
+ * 鐜板満鏍囩鍗扮殑鏄拷婧簩缁寸爜锛屽唴瀹逛负銆岃拷婧綉鍧�?code=杩芥函鐮併�嶏紱
+ * 鎵爜鏋篃鍙兘鎶婃暣娈电綉鍧�鐩存帴鎻愪氦锛屾墍鏈夋壂鐮佸叆鍙i兘搴斿厛缁忚繃杩欓噷瑙勮寖鍖栥��
+ * 鍚庣 MesTraceCodeUtils 淇濇寔鍚屼竴鍙e緞銆�
+ */
+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('杩芥函鐮佹牸寮忎笉姝g‘');
+  }
+  return value;
+}
diff --git a/src/utils/traceLookup.ts b/src/utils/traceLookup.ts
new file mode 100644
index 0000000..a3489b9
--- /dev/null
+++ b/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;
+}
+
+/** 瑙f瀽鎵弿鍘熸枃锛屾寜鐮佺被鍨嬫煡璇㈣鐮� / 鎵樼洏 / 鎵规瀵硅薄銆� */
+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;
+}
diff --git a/src/views/mobile/miscissue.vue b/src/views/mobile/miscissue.vue
new file mode 100644
index 0000000..37f3b15
--- /dev/null
+++ b/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 ? '姝e湪鏌ヨ鈥�' : '鏌ヨ鑽夌鍑哄簱鍗�' }}</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 ? '姝e湪鍔犺浇鈥�' : '鍔犺浇鏇村' }}
+    </button>
+  </template>
+</template>
diff --git a/src/views/mobile/productissue.vue b/src/views/mobile/productissue.vue
new file mode 100644
index 0000000..9632a1a
--- /dev/null
+++ b/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 ? '姝e湪鏌ヨ鈥�' : '鏌ヨ鑽夌棰嗘枡鍗�' }}</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 ? '姝e湪鍔犺浇鈥�' : '鍔犺浇鏇村' }}
+    </button>
+  </template>
+</template>
diff --git a/src/views/mobile/returnissue.vue b/src/views/mobile/returnissue.vue
new file mode 100644
index 0000000..0287f84
--- /dev/null
+++ b/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 ? '姝e湪鏌ヨ鈥�' : '鏌ヨ鑽夌閫�鏂欏崟' }}</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 ? '姝e湪鍔犺浇鈥�' : '鍔犺浇鏇村' }}
+    </button>
+  </template>
+</template>
diff --git a/src/views/mobile/returnsales.vue b/src/views/mobile/returnsales.vue
new file mode 100644
index 0000000..411c39c
--- /dev/null
+++ b/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 ? '姝e湪鏌ヨ鈥�' : '鏌ヨ鑽夌閫�璐у崟' }}</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 ? '姝e湪鍔犺浇鈥�' : '鍔犺浇鏇村' }}
+    </button>
+  </template>
+</template>
diff --git a/src/views/mobile/stocktaking.vue b/src/views/mobile/stocktaking.vue
new file mode 100644
index 0000000..39be383
--- /dev/null
+++ b/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 ? '姝e湪鏌ヨ鈥�' : '鏌ヨ鐩樼偣涓换鍔�' }}</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 ? '姝e湪鍔犺浇鈥�' : '鍔犺浇鏇村' }}
+    </button>
+  </template>
+</template>
diff --git a/src/views/mobile/transfer.vue b/src/views/mobile/transfer.vue
new file mode 100644
index 0000000..31bb3f8
--- /dev/null
+++ b/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 ? '姝e湪鏌ヨ鈥�' : '鏌ヨ鑽夌杞Щ鍗�' }}</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 ? '姝e湪鍔犺浇鈥�' : '鍔犺浇鏇村' }}
+    </button>
+  </template>
+</template>
diff --git a/src/views/wls/miscissue/modules/line-form.vue b/src/views/wls/miscissue/modules/line-form.vue
index bd689a6..895ea6e 100644
--- a/src/views/wls/miscissue/modules/line-form.vue
+++ b/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();
diff --git a/src/views/wls/miscissue/modules/line-list.vue b/src/views/wls/miscissue/modules/line-list.vue
index 8f8e9be..f89b87d 100644
--- a/src/views/wls/miscissue/modules/line-list.vue
+++ b/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
diff --git a/src/views/wls/productissue/modules/line-form.vue b/src/views/wls/productissue/modules/line-form.vue
index 8bf9782..7ba2af7 100644
--- a/src/views/wls/productissue/modules/line-form.vue
+++ b/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();
diff --git a/src/views/wls/productissue/modules/line-list.vue b/src/views/wls/productissue/modules/line-list.vue
index 9c08bf1..7c872a5 100644
--- a/src/views/wls/productissue/modules/line-list.vue
+++ b/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
diff --git a/src/views/wls/productreceipt/modules/line-form.vue b/src/views/wls/productreceipt/modules/line-form.vue
index b9e3716..d5494bf 100644
--- a/src/views/wls/productreceipt/modules/line-form.vue
+++ b/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();
diff --git a/src/views/wls/productreceipt/modules/line-list.vue b/src/views/wls/productreceipt/modules/line-list.vue
index 26442fb..78324e9 100644
--- a/src/views/wls/productreceipt/modules/line-list.vue
+++ b/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
diff --git a/src/views/wls/productsales/modules/form.vue b/src/views/wls/productsales/modules/form.vue
index 27ee471..c05ba79 100644
--- a/src/views/wls/productsales/modules/form.vue
+++ b/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">鎷h揣鎵爜绫诲瀷</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">鏍稿鎷h揣瀵硅薄</h3><p>鎵规 {{scanPreview.batchCode}} 路 鍙嫞 {{scanPreview.scanQuantity}} 琚�</p>
diff --git a/src/views/wls/returnissue/modules/line-form.vue b/src/views/wls/returnissue/modules/line-form.vue
index fc72468..50d90c0 100644
--- a/src/views/wls/returnissue/modules/line-form.vue
+++ b/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();
diff --git a/src/views/wls/returnissue/modules/line-list.vue b/src/views/wls/returnissue/modules/line-list.vue
index 98c6682..9ae3d05 100644
--- a/src/views/wls/returnissue/modules/line-list.vue
+++ b/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
diff --git a/src/views/wls/returnsales/modules/line-form.vue b/src/views/wls/returnsales/modules/line-form.vue
index b80815a..f9f9323 100644
--- a/src/views/wls/returnsales/modules/line-form.vue
+++ b/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();
diff --git a/src/views/wls/returnsales/modules/line-list.vue b/src/views/wls/returnsales/modules/line-list.vue
index eac0e37..2e78bf0 100644
--- a/src/views/wls/returnsales/modules/line-list.vue
+++ b/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
diff --git a/src/views/wls/stocktaking/task/modules/result-list.vue b/src/views/wls/stocktaking/task/modules/result-list.vue
index b253a77..863bb11 100644
--- a/src/views/wls/stocktaking/task/modules/result-list.vue
+++ b/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>
diff --git a/src/views/wls/transfer/modules/line-form.vue b/src/views/wls/transfer/modules/line-form.vue
index c4c41f6..2195306 100644
--- a/src/views/wls/transfer/modules/line-form.vue
+++ b/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();
diff --git a/src/views/wls/transfer/modules/line-list.vue b/src/views/wls/transfer/modules/line-list.vue
index 05d8165..c8f3349 100644
--- a/src/views/wls/transfer/modules/line-list.vue
+++ b/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

--
Gitblit v1.9.3