云
21 小时以前 874afbfb8eec12fe6ca60565c80f620f68e30d62
feat(qc): 拆分部门审批流程并支持扫码自动识别

- 拆分部门审批为加工部与销售部独立流程
- 监听器重构以同时支持两个部门审批流程
- 质检扫码新增自动识别袋码托盘及批次码功能
- 移除移动端手动选择扫码类型改为自动识别
- 审批详情页根据质检线动态展示对应部门标题
- 库存事务流水列表新增计量单位名称字段展示
- 更新质检双线集成及流程迁移相关说明文档
已修改8个文件
54 ■■■■■ 文件已修改
src/api/mes/qc/seedQuality/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/transaction/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/seedQuality/process-detail.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/quality.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/sales.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/scanner.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/materialstock/data.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transaction/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/seedQuality/index.ts
@@ -244,7 +244,7 @@
  scanType?: 'BAG' | 'PALLET' | 'BATCH'; totalCount?: number;
  pendingBagIds?: number[]; completedBagIds?: number[];
}
export function resolveSeedQuality(code: string, scanType: 'BAG' | 'PALLET' | 'BATCH' = 'BAG', qcLine: 1 | 2 = 1) {
export function resolveSeedQuality(code: string, scanType: 'AUTO' | 'BAG' | 'PALLET' | 'BATCH' = 'AUTO', qcLine: 1 | 2 = 1) {
  return requestClient.get<SeedQualityScan>('/mes/qc/seed-quality/resolve', { params: { code, scanType, qcLine }, suppressErrorMessage: true });
}
export function submitSeedQualityGroup(data: MesQcSeedQualityApi.SubmitParams & { scanType: 'BAG' | 'PALLET' | 'BATCH'; bagIds: number[] }) {
src/api/mes/wm/transaction/index.ts
@@ -15,6 +15,7 @@
    itemCode?: string; // 物料编码
    itemName?: string; // 物料名称
    specification?: string; // 规格型号
    unitMeasureName?: string; // 计量单位名称
    itemTypeName?: string; // 物料类别名称
    quantity?: number; // 变动数量
    batchId?: number; // 批次ID
src/views/mes/qc/seedQuality/process-detail.vue
@@ -24,6 +24,7 @@
}>();
const LINE_PROCESS_KEY = 'mes_seed_quality_line_approve';
const SALES_LINE_PROCESS_KEY = 'mes_seed_quality_sales_line_approve';
const COMPANY_PROCESS_KEY = 'mes_seed_quality_company_approve';
const RETURN_PROCESS_KEY = 'mes_seed_return_approve';
@@ -100,7 +101,10 @@
});
const modeLabel = computed(() => {
  if (mode.value === 'line') return '种子质检 · 部门审批';
  if (mode.value === 'line') {
    const lineLabel = qcLineLabels[parsedLine.value ?? 0];
    return parsedLine.value && lineLabel ? `种子质检 · ${lineLabel}审批` : '种子质检 · 部门审批';
  }
  if (mode.value === 'company') return '种子质检 · 公司质检审批';
  if (mode.value === 'return') return '经销商退货 · 质检审批';
  return '种子质检';
@@ -166,7 +170,7 @@
function detectMode(): DetailMode {
  const key = props.processDefinitionKey;
  if (key === LINE_PROCESS_KEY) return 'line';
  if (key === LINE_PROCESS_KEY || key === SALES_LINE_PROCESS_KEY) return 'line';
  if (key === COMPANY_PROCESS_KEY) return 'company';
  if (key === RETURN_PROCESS_KEY) return 'return';
  if (businessKey.value.includes('#')) return 'line';
src/views/mobile/quality.vue
@@ -14,7 +14,6 @@
const warehouses = ref<MesWmWarehouseApi.Warehouse[]>([]), warehouseId = ref<number>();
const reason = ref(''), pendingCode = ref('');
type ScanType = 'BAG' | 'PALLET' | 'BATCH';
const scanType = ref<ScanType>('BAG');
const typeNames = { BAG: '袋码', PALLET: '托盘码', BATCH: '生产批次码' };
const pendingType = ref<ScanType>('BAG'), pendingIds = ref<number[]>([]);
const storageKey = 'mom-mobile-quality-' + useUserStore().userInfo?.id;
@@ -26,7 +25,8 @@
  if (busy.value || pendingCode.value) return;
  busy.value = true; error.value = ''; notice.value = ''; bag.value = undefined;
  try {
    const data = await resolveSeedQuality(extractCode(raw), scanType.value);
    // 与扫码绑定托盘一致:不预先选择类型,由后端按码值自动识别袋码 / 托盘码 / 生产批次码
    const data = await resolveSeedQuality(extractCode(raw));
    if (data.canceled || !data.inbound) throw new Error('该对象已作废或有效袋码尚未全部入库,不能质检');
    if (!data.pendingBagIds?.length) { if (data.completedBagIds?.length) speakDuplicateScan('quality'); throw new Error('该对象没有待质检袋码,请在质检记录中查看已有结果'); }
    if (!data.indicators?.length) throw new Error('该产品尚未配置质检项,请联系管理员维护');
@@ -44,10 +44,10 @@
  const count = bag.value.pendingBagIds!.length;
  if (!window.confirm(passed.value ? `确认对本次 ${count} 袋提交初检合格?将同时生成加工部、销售部两条记录并进入部门审批。` : `确认对本次 ${count} 袋提交初检不合格?将同时生成加工部、销售部两条记录并进入复检。`)) return;
  error.value = ''; notice.value = '';
  pendingCode.value = bag.value.code; pendingType.value = scanType.value; pendingIds.value = [...bag.value.pendingBagIds!];
  pendingCode.value = bag.value.code; pendingType.value = bag.value.scanType || 'BAG'; pendingIds.value = [...bag.value.pendingBagIds!];
  sessionStorage.setItem(storageKey, JSON.stringify({ code: pendingCode.value, scanType: pendingType.value, bagIds: pendingIds.value })); busy.value = true;
  try {
    const result = await submitSeedQualityGroup({ code: bag.value.code, scanType: scanType.value, bagIds: pendingIds.value, qualifiedWarehouseId: warehouseId.value, reason: reason.value.trim() || undefined, indicators: indicators.value.map(i => ({ code: i.code, value: Number(i.value), minValue: Number(i.minValue), maxValue: Number(i.maxValue) })) });
    const result = await submitSeedQualityGroup({ code: bag.value.code, scanType: bag.value.scanType || 'BAG', bagIds: pendingIds.value, qualifiedWarehouseId: warehouseId.value, reason: reason.value.trim() || undefined, indicators: indicators.value.map(i => ({ code: i.code, value: Number(i.value), minValue: Number(i.minValue), maxValue: Number(i.maxValue) })) });
    clearPending(); notice.value = `本次 ${result.processedCount} 袋` + (passed.value ? '初检合格,已同步生成加工部、销售部两条记录并进入部门审批。' : '初检不合格,已同步生成加工部、销售部两条记录,等待复检。');
    bag.value = undefined; reason.value = ''; indicators.value.forEach(i => i.value = '');
  } catch (e) { if (/已质检|已全部质检/.test(errorText(e))) speakDuplicateScan('quality'); error.value = '提交结果待确认:' + errorText(e) + '。请核实后再操作下一袋。'; } finally { busy.value = false; }
@@ -80,11 +80,10 @@
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <div v-if="pendingCode" class="m-panel"><h2 class="m-title">质检结果待确认</h2><p>{{pendingCode}}</p><button class="m-primary" :disabled="busy" @click="verify">核实质检结果</button></div>
  <template v-else>
    <label class="m-field"><span>质检扫码类型</span><select v-model="scanType" :disabled="busy" @change="bag = undefined; notice = ''; error = ''"><option value="BAG">袋码</option><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label>
    <p class="m-muted">扫描{{typeNames[scanType]}},核对待检袋数,统一录入该产品的全部质检项。一次提交会同时生成加工部、销售部两条质检记录;已检、作废袋码不会重复处理。</p>
    <p class="m-muted">扫描袋码、托盘码或生产批次码(支持整段追溯链接),系统自动识别类型并核对待检袋数,统一录入该产品的全部质检项。一次提交会同时生成加工部、销售部两条质检记录;已检、作废袋码不会重复处理。</p>
    <Scanner voice-operation="quality" duplicate-voice :disabled="busy" @code="identify" />
    <form v-if="bag" @submit.prevent="submit">
      <div class="m-panel"><h2 class="m-title">本次检验对象</h2><dl><div class="m-row"><dt>对象类型</dt><dd>{{typeNames[scanType]}}</dd></div><div class="m-row"><dt>对象码值</dt><dd>{{bag.code}}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{bag.batchCode || '—'}}</dd></div><div class="m-row"><dt>有效袋数</dt><dd>{{bag.totalCount}}</dd></div><div class="m-row"><dt>已检袋数</dt><dd>{{bag.completedBagIds?.length || 0}}</dd></div><div class="m-row"><dt>本次待检</dt><dd>{{bag.pendingBagIds?.length || 0}} 袋</dd></div></dl><p v-if="scanType !== 'BAG'" class="m-warning">以下指标和检验结论将应用于本次全部待检袋码,请确认抽样结果适用于这{{bag.pendingBagIds?.length}}袋。</p></div>
      <div class="m-panel"><h2 class="m-title">本次检验对象</h2><dl><div class="m-row"><dt>对象类型</dt><dd>{{typeNames[bag.scanType || 'BAG']}}</dd></div><div class="m-row"><dt>对象码值</dt><dd>{{bag.code}}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{bag.batchCode || '—'}}</dd></div><div class="m-row"><dt>有效袋数</dt><dd>{{bag.totalCount}}</dd></div><div class="m-row"><dt>已检袋数</dt><dd>{{bag.completedBagIds?.length || 0}}</dd></div><div class="m-row"><dt>本次待检</dt><dd>{{bag.pendingBagIds?.length || 0}} 袋</dd></div></dl><p v-if="bag.scanType !== 'BAG'" class="m-warning">以下指标和检验结论将应用于本次全部待检袋码,请确认抽样结果适用于这{{bag.pendingBagIds?.length}}袋。</p></div>
      <fieldset v-for="i in indicators" :key="i.code" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{i.name}}({{i.unit || '%'}})</legend>
        <label class="m-field"><span>实测值</span><input v-model="i.value" type="number" inputmode="decimal" min="0" step="any" required placeholder="输入实测值" /></label>
        <div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="i.minValue" type="number" inputmode="decimal" min="0" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="i.maxValue" type="number" inputmode="decimal" min="0" step="any" required /></label></div>
src/views/mobile/sales.vue
@@ -27,7 +27,7 @@
const shipments = ref<MesWmProductSalesApi.Shipment[]>([]);
const shipmentNames: Record<number, string> = { 0: '拣货中', 10: '待填写运单', 20: '待执行出库', 30: '已出库', 40: '已取消' };
const carrier = ref(''), shippingNumber = ref('');
const scanType = ref<'BAG' | 'PALLET' | 'BATCH'>('BAG'), selectedBagIds = ref<number[]>([]);
const selectedBagIds = ref<number[]>([]);
const manualLine = ref<Line>(), manualStock = ref<MesWmMaterialStockApi.MaterialStock>();
const manualStocks = ref<MesWmMaterialStockApi.MaterialStock[]>([]), stocksLoaded = ref(false);
const manualQuantity = ref<number>();
@@ -79,7 +79,8 @@
  busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined; manualLine.value = undefined; manualStock.value = undefined; manualStocks.value = []; stocksLoaded.value = false;
  try {
    selectedBagIds.value = [];
    const data = await previewProductSalesScan(extractCode(raw), scanType.value, sales.value?.id);
    // 与扫码绑定托盘一致:不预先选择类型,由后端按码值自动识别袋码 / 托盘码 / 生产批次码
    const data = await previewProductSalesScan(extractCode(raw), undefined, sales.value?.id);
    if (data.scanType === 'BAG' && data.codeId && details.value.some(d => d.bagCodeId === data.codeId)) { speakDuplicateScan('outbound'); throw new Error('该袋码已在拣货清单中,请勿重复扫描'); }
    if (!data.codeId || !data.scanQuantity || data.scanQuantity <= 0) throw new Error('无法确认扫码数量,请核对码值');
    const line = lines.value.find(l => l.itemId === data.itemId);
@@ -205,7 +206,7 @@
      <button class="m-detail-toggle" :disabled="busy || !!preview" @click="manualLine = undefined; manualStocks = []; stocksLoaded = false">关闭库存选择</button>
    </section>
    <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 voice-operation="outbound" duplicate-voice :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>
      <template v-if="picking"><h2 class="m-section-title">扫码拣货</h2><p class="m-muted">扫描袋码、托盘码或生产批次码(支持整段追溯链接),系统自动识别类型;托盘、批次识别后选择实际拣货袋码,数量按所选袋数计入。</p><Scanner voice-operation="outbound" duplicate-voice :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>
      <button v-if="picking && shipments.some(s => s.status === 30) && can('mes:wm-product-sales:cancel')" class="m-secondary" :disabled="busy || !!preview" @click="execute('close')">关闭剩余数量</button>
src/views/mobile/scanner.vue
@@ -2,13 +2,22 @@
import { nextTick, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import Icon from '#/mobile/icon.vue';
import { speakDuplicateScan, type ScanVoiceOperation } from '#/mobile/scanVoice';
import { extractCode } from '#/utils/traceCode';
const props=defineProps<{disabled?:boolean;continuous?:boolean;duplicateVoice?:boolean;voiceOperation?:ScanVoiceOperation}>();
const emit=defineEmits<{code:[value:string]}>();
const input=ref(''),error=ref(''),active=ref(false),starting=ref(false),video=ref<HTMLVideoElement>(),codeInput=ref<HTMLInputElement>();
let stream:MediaStream|undefined,frame=0,generation=0,last=0;
async function testVoice(){error.value='';if(!await speakDuplicateScan(props.voiceOperation))error.value='语音播放失败,请检查媒体音量、浏览器声音权限和网络。';}
function stop(){generation++;active.value=false;starting.value=false;cancelAnimationFrame(frame);stream?.getTracks().forEach(t=>t.stop());stream=undefined;if(video.value)video.value.srcObject=null;}
function accept(value:string){if(props.disabled)return;stop();input.value=props.continuous?'':value;emit('code',value);}
function accept(value:string){
  if(props.disabled)return;
  stop();
  // 标签二维码内容可能是整段追溯链接,统一拆解为纯码值后再上屏和回调,避免把链接当码值使用
  let normalized=value;
  try{normalized=extractCode(value);}catch{/* 非追溯码格式按原始内容处理 */}
  input.value=props.continuous?'':normalized;
  emit('code',normalized);
}
watch([()=>props.disabled,()=>props.continuous],async ([disabled,continuous])=>{if(!disabled&&continuous){await nextTick();codeInput.value?.focus();}});
async function start(){
  error.value='';stop();const ticket=generation;starting.value=true;
src/views/wls/materialstock/data.ts
@@ -96,12 +96,12 @@
      minWidth: 140,
    },
    {
      field: 'specification',
      field: 'unitMeasureName',
      title: '规格',
      minWidth: 120,
    },
    {
      field: 'itemTypeName',
      field: 'specification',
      title: '类别',
      minWidth: 120,
    },
@@ -267,12 +267,12 @@
      minWidth: 140,
    },
    {
      field: 'specification',
      field: 'unitMeasureName',
      title: '规格',
      minWidth: 120,
    },
    {
      field: 'itemTypeName',
      field: 'specification',
      title: '类别',
      minWidth: 120,
    },
src/views/wls/transaction/data.ts
@@ -134,12 +134,12 @@
      minWidth: 150,
    },
    {
      field: 'specification',
      field: 'unitMeasureName',
      title: '规格',
      minWidth: 120,
    },
    {
      field: 'itemTypeName',
      field: 'specification',
      title: '类别',
      minWidth: 120,
    },