云
6 小时以前 fb4db4ae9b6a7f113ba049f6789e6916c0dcc276
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
<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>