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