<script setup lang="ts">
|
import { computed, onMounted, ref } from 'vue';
|
import { onBeforeRouteLeave } from 'vue-router';
|
import { useUserStore } from '@vben/stores';
|
import { getProductSalesPage, getProductSales, previewProductSalesScan, scanProductSales, checkProductSalesQuantity, stockProductSales, shippingProductSales, finishProductSales, type MesWmProductSalesApi } from '#/api/mes/wm/productsales';
|
import { getProductSalesLinePage, type MesWmProductSalesLineApi } from '#/api/mes/wm/productsales/line';
|
import { getProductSalesDetailListByLineId, type MesWmProductSalesDetailApi } from '#/api/mes/wm/productsales/detail';
|
import { can, extractCode } from '#/mobile/access';
|
import { errorText } from '#/mobile/api';
|
import Scanner from './scanner.vue';
|
import PickingBags from '../wls/productsales/modules/picking-bags.vue';
|
|
type Sales = MesWmProductSalesApi.ProductSales;
|
type Preview = MesWmProductSalesApi.ProductSalesScanResponse;
|
type Line = MesWmProductSalesLineApi.ProductSalesLine;
|
type Detail = MesWmProductSalesDetailApi.ProductSalesDetail;
|
type Action = 'scan' | 'stock' | 'shipping' | 'finish';
|
type Pending = { salesId: number; action: Action; before: number; preview?: Preview; carrier?: string; shippingNumber?: string };
|
const statusNames: Record<number, string> = { 0: '草稿', 1: '待检测', 2: '待拣货', 10: '待填写运单', 3: '待执行出库', 4: '已完成', 5: '已取消' };
|
const busy = ref(false), error = ref(''), notice = ref(''), code = ref(''), status = ref('');
|
const rows = ref<Sales[]>([]), page = ref(0), total = ref(0), sales = ref<Sales>();
|
const lines = ref<Line[]>([]), details = ref<Detail[]>([]), preview = ref<Preview>(), pending = ref<Pending>();
|
const carrier = ref(''), shippingNumber = ref('');
|
const scanType = ref<'BAG' | 'PALLET' | 'BATCH'>('BAG'), selectedBagIds = ref<number[]>([]);
|
const storageKey = 'mom-mobile-sales-' + useUserStore().userInfo?.id;
|
let filters: { code?: string; status?: number } = {};
|
const picking = computed(() => sales.value?.status === 2 && can('mes:wm-product-sales:stock'));
|
function clearPending() { pending.value = undefined; sessionStorage.removeItem(storageKey); }
|
async function load(reset = true) {
|
if (busy.value || pending.value) return;
|
busy.value = true; error.value = '';
|
const next = reset ? 1 : page.value + 1;
|
const query = reset ? { code: code.value.trim() || undefined, status: status.value === '' ? undefined : Number(status.value) } : filters;
|
try { const data = await getProductSalesPage({ ...query, 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 readOrder(id: number) {
|
const order = await getProductSales(id);
|
if (!order) throw new Error('出库单不存在或已被删除');
|
const all: Line[] = [];
|
for (let n = 1; ; n++) {
|
const data = await getProductSalesLinePage({ salesId: id, pageNo: n, pageSize: 100 });
|
all.push(...data.list);
|
if (all.length >= data.total || !data.list.length) break;
|
}
|
const picked = (await Promise.all(all.filter(l => l.id != null).map(l => getProductSalesDetailListByLineId(l.id!)))).flat();
|
sales.value = order; lines.value = all; details.value = picked;
|
carrier.value = order.carrier || ''; shippingNumber.value = order.shippingNumber || '';
|
}
|
async function open(id?: number) {
|
if (!id || busy.value || pending.value) return;
|
busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined;
|
try { await readOrder(id); } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
|
}
|
function pickedQuantity(lineId?: number) { return details.value.filter(d => d.lineId === lineId).reduce((sum, d) => sum + Number(d.quantity || 0), 0); }
|
async function identify(raw: string) {
|
if (busy.value || !picking.value || pending.value) return;
|
busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined;
|
try {
|
selectedBagIds.value = [];
|
const data = await previewProductSalesScan(extractCode(raw), scanType.value, sales.value?.id);
|
if (!data.codeId || !data.scanQuantity || data.scanQuantity <= 0) throw new Error('无法确认扫码数量,请核对码值');
|
const line = lines.value.find(l => l.itemId === data.itemId);
|
if (!line) throw new Error('扫码品种不在当前出库单中');
|
data.itemName = line.itemName; preview.value = data;
|
if (data.scanType === 'BAG') selectedBagIds.value = data.bags?.filter(b => b.selectable).map(b => b.id) || [];
|
} catch (e) { error.value = errorText(e); } finally { busy.value = false; }
|
}
|
async function execute(action: Action) {
|
const order = sales.value;
|
if (!order?.id || busy.value || pending.value) return;
|
const permission = action === 'scan' ? 'stock' : action;
|
if (!can('mes:wm-product-sales:' + permission)) return;
|
if (action === 'scan' && (!picking.value || !preview.value)) return;
|
if (action === 'scan' && !selectedBagIds.value.length) { error.value = '请选择本次拣货对应的袋码'; return; }
|
if (action === 'stock' && order.status !== 2 || action === 'shipping' && order.status !== 10 || action === 'finish' && order.status !== 3) return;
|
if (action === 'shipping' && (!carrier.value.trim() || !shippingNumber.value.trim())) { error.value = '请填写承运商和运输单号'; return; }
|
const message = action === 'scan' ? `确认将所选 ${selectedBagIds.value.length} 袋加入本单拣货并关联追溯袋码?` : action === 'stock' ? '确认完成拣货?系统将核对各品种数量。' : action === 'shipping' ? '确认保存运单并进入待执行出库?' : '确认执行出库?本单将扣减库存并标记已完成。';
|
if (!window.confirm(message)) return;
|
busy.value = true; error.value = ''; notice.value = '';
|
try {
|
if (action === 'stock' && !await checkProductSalesQuantity(order.id)) throw new Error('拣货数量与出库数量不一致,请核对每个品种');
|
const operation: Pending = { salesId: order.id, action, before: order.status!, preview: action === 'scan' ? { ...preview.value, selectedBagIds: [...selectedBagIds.value], scanQuantity: selectedBagIds.value.length } : undefined, carrier: carrier.value.trim(), shippingNumber: shippingNumber.value.trim() };
|
pending.value = operation; sessionStorage.setItem(storageKey, JSON.stringify(operation));
|
if (action === 'scan') {
|
const result = await scanProductSales({ salesId: order.id, scanContent: operation.preview!.scanContent!, scanType: operation.preview!.scanType as 'BAG' | 'PALLET' | 'BATCH', bagIds: operation.preview!.selectedBagIds, quantity: operation.preview!.scanQuantity });
|
notice.value = result.alreadyScanned ? '该码已在拣货清单中,本次未重复添加。' : `已加入拣货:${result.outboundQuantity || operation.preview!.scanQuantity} 袋。`;
|
} else if (action === 'stock') { await stockProductSales(order.id); notice.value = '拣货完成,请填写运单。'; }
|
else if (action === 'shipping') { await shippingProductSales({ id: order.id, carrier: operation.carrier, shippingNumber: operation.shippingNumber }); notice.value = '运单已保存,请核对后执行出库。'; }
|
else { await finishProductSales(order.id); notice.value = '出库完成,库存已更新。'; }
|
clearPending(); preview.value = undefined;
|
await readOrder(order.id);
|
} catch (e) { error.value = (pending.value ? '操作结果待确认,请先核实:' : '') + errorText(e); } finally { busy.value = false; }
|
}
|
async function verify() {
|
if (!pending.value || busy.value) return;
|
busy.value = true; error.value = '';
|
try {
|
const operation = pending.value;
|
await readOrder(operation.salesId);
|
const requestedIds = operation.preview?.selectedBagIds;
|
const found = operation.action === 'scan' ? requestedIds?.length ? requestedIds.every(id => details.value.some(d => d.bagCodeId === id)) : details.value.some(d => operation.preview?.scanType === 'PALLET' ? d.palletCodeId === operation.preview.codeId : d.bagCodeId === operation.preview?.codeId) : sales.value?.status !== operation.before;
|
if (operation.action === 'scan' && !found && requestedIds?.some(id => details.value.some(d => d.bagCodeId === id))) { error.value = '所选袋码仅部分存在于拣货明细,请联系负责人核对,不能自动重试。'; return; }
|
if (found) {
|
notice.value = operation.action === 'scan' ? '已核实:该码已在拣货清单中。' : '当前单据状态:' + statusNames[sales.value!.status!] + '。请核对明细和运单后继续。';
|
clearPending(); preview.value = undefined;
|
} else if (window.confirm('当前尚未发现该操作结果。确认原请求已结束后,可重新核对再操作。继续?')) { clearPending(); preview.value = undefined; notice.value = '请核对当前单据与拣货清单后重新操作。'; }
|
} catch (e) { error.value = errorText(e); } finally { busy.value = false; }
|
}
|
onMounted(async () => {
|
try { pending.value = JSON.parse(sessionStorage.getItem(storageKey) || 'null') || undefined; } catch { sessionStorage.removeItem(storageKey); }
|
if (pending.value) { busy.value = true; try { await readOrder(pending.value.salesId); } catch (e) { error.value = errorText(e); } finally { busy.value = false; } }
|
else await load();
|
});
|
onBeforeRouteLeave(() => !busy.value && (!preview.value || !!pending.value || window.confirm('离开将放弃尚未确认的扫码对象,是否继续?')));
|
</script>
|
<template>
|
<p v-if="notice" class="m-success" role="status">{{notice}}</p>
|
<p v-if="error" class="m-error" role="alert">{{error}}</p>
|
<section v-if="pending" class="m-panel"><h2 class="m-title">出库操作结果待确认</h2><p>单据编号 {{pending.salesId}},请先核实再继续操作。</p><button class="m-primary" :disabled="busy" @click="verify">核实操作结果</button></section>
|
<template v-if="sales">
|
<button v-if="!pending" class="m-secondary" :disabled="busy" @click="sales = undefined; preview = undefined; load()">返回出库单列表</button>
|
<section class="m-panel" style="margin-top:16px"><h2 class="m-title">{{sales.code}}</h2><strong class="m-status">{{statusNames[sales.status!] || '未知状态'}}</strong><dl><div class="m-row"><dt>客户</dt><dd>{{sales.clientName || '—'}}</dd></div><div class="m-row"><dt>收货人</dt><dd>{{sales.contactName || '—'}} {{sales.contactTelephone}}</dd></div><div class="m-row"><dt>收货地址</dt><dd>{{sales.contactAddress || '—'}}</dd></div><div v-if="sales.shippingNumber" class="m-row"><dt>运单</dt><dd>{{sales.carrier}} · {{sales.shippingNumber}}</dd></div></dl><button v-if="!pending" class="m-secondary" :disabled="busy" @click="open(sales.id)">刷新单据</button></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}} {{line.unitMeasureName || '袋'}} · 已拣 {{pickedQuantity(line.id)}} {{line.unitMeasureName || '袋'}}</p><details><summary>查看拣货明细</summary><div v-for="detail in details.filter(d => d.lineId === line.id)" :key="detail.id" class="m-record"><p>{{detail.batchCode || '未录入批次'}} · {{detail.quantity}} {{line.unitMeasureName || '袋'}}</p><p class="m-muted">{{detail.warehouseName || '—'}} / {{detail.locationName || '—'}} / {{detail.areaName || '—'}}</p><p class="m-muted">{{detail.bagCodeId ? '袋码编号 ' + detail.bagCodeId : '托盘编号 ' + detail.palletCodeId}}</p><p v-if="detail.remark" class="m-muted" style="overflow-wrap:anywhere">{{detail.remark}}</p></div></details></article>
|
<template v-if="!pending">
|
<template v-if="picking"><h2 class="m-section-title">扫码拣货</h2><label class="m-field"><span>拣货扫码类型</span><select v-model="scanType" :disabled="busy" @change="preview = undefined; selectedBagIds = []"><option value="BAG">袋码</option><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label><p class="m-muted">支持业务码与追溯码。托盘、批次识别后选择实际拣货袋码,数量按所选袋数计入。</p><Scanner :disabled="busy" @code="identify" /><section v-if="preview" class="m-panel"><h3 class="m-title">核对拣货对象</h3><p>{{preview.itemName}}</p><p style="overflow-wrap:anywhere">{{preview.scanContent}}</p><p>批次 {{preview.batchCode}} · 可拣 {{preview.scanQuantity}} 袋</p><PickingBags v-if="preview.scanType !== 'BAG'" v-model="selectedBagIds" :bags="preview.bags || []" :disabled="busy" /><p v-else>袋码 {{preview.bags?.[0]?.code}}</p><p role="status">本次拣货 {{selectedBagIds.length}} 袋</p><button class="m-primary" :disabled="busy || !selectedBagIds.length" @click="execute('scan')">确认加入拣货</button><button class="m-detail-toggle" :disabled="busy" @click="preview = undefined">取消本次扫码</button></section></template>
|
<form v-if="sales.status === 10 && can('mes:wm-product-sales:shipping')" class="m-panel" @submit.prevent="execute('shipping')"><h2 class="m-title">填写运单</h2><label class="m-field"><span>承运商</span><input v-model="carrier" :disabled="busy" maxlength="100" required /></label><label class="m-field"><span>运输单号</span><input v-model="shippingNumber" :disabled="busy" maxlength="100" required /></label><button class="m-primary" :disabled="busy">保存运单</button></form>
|
<div v-if="!preview" class="m-action-bar"><button v-if="picking" class="m-primary" :disabled="busy || !lines.length" @click="execute('stock')">完成拣货并核对数量</button><button v-if="sales.status === 3 && can('mes:wm-product-sales:finish')" class="m-primary" :disabled="busy" @click="execute('finish')">确认执行出库</button></div>
|
<p v-if="sales.status === 0 || sales.status === 1" class="m-warning">{{sales.status === 0 ? '该单为草稿,请在 PC 端维护品种并提交后进行移动拣货。' : '该单待检测,检测完成后才能拣货。'}}</p>
|
<p v-if="sales.status === 4" class="m-success">该单已完成出库。</p>
|
</template>
|
</template>
|
<template v-else-if="!pending">
|
<form class="m-panel" @submit.prevent="load()"><label class="m-field"><span>出库单号</span><input v-model="code" :disabled="busy" placeholder="输入出库单号" /></label><label class="m-field"><span>单据状态</span><select v-model="status" :disabled="busy"><option value="">全部状态</option><option v-for="(name, value) in statusNames" :key="value" :value="String(value)">{{name}}</option></select></label><button class="m-secondary" :disabled="busy">{{busy ? '正在查询…' : '查询出库单'}}</button></form>
|
<p class="m-muted" role="status">共 {{total}} 张出库单</p><p v-if="!rows.length && !busy && !error" class="m-empty">暂无匹配的出库单</p>
|
<button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)"><strong>{{row.code}}</strong><span>{{statusNames[row.status!] || '未知状态'}}</span><small>{{row.clientName || '未录入客户'}}</small></button>
|
<button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{busy ? '正在加载…' : '加载更多'}}</button>
|
</template>
|
</template>
|