feat(qc): 拆分部门审批流程并支持扫码自动识别
- 拆分部门审批为加工部与销售部独立流程
- 监听器重构以同时支持两个部门审批流程
- 质检扫码新增自动识别袋码托盘及批次码功能
- 移除移动端手动选择扫码类型改为自动识别
- 审批详情页根据质检线动态展示对应部门标题
- 库存事务流水列表新增计量单位名称字段展示
- 更新质检双线集成及流程迁移相关说明文档
| | |
| | | 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[] }) { |
| | |
| | | itemCode?: string; // 物料编码 |
| | | itemName?: string; // 物料名称 |
| | | specification?: string; // 规格型号 |
| | | unitMeasureName?: string; // 计量单位名称 |
| | | itemTypeName?: string; // 物料类别名称 |
| | | quantity?: number; // 变动数量 |
| | | batchId?: number; // 批次ID |
| | |
| | | }>(); |
| | | |
| | | 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'; |
| | | |
| | |
| | | }); |
| | | |
| | | 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 '种子质检'; |
| | |
| | | |
| | | 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'; |
| | |
| | | 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; |
| | |
| | | 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('该产品尚未配置质检项,请联系管理员维护'); |
| | |
| | | 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; } |
| | |
| | | <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> |
| | |
| | | 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>(); |
| | |
| | | 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); |
| | |
| | | <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> |
| | |
| | | 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; |
| | |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'specification', |
| | | field: 'unitMeasureName', |
| | | title: '规格', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'itemTypeName', |
| | | field: 'specification', |
| | | title: '类别', |
| | | minWidth: 120, |
| | | }, |
| | |
| | | minWidth: 140, |
| | | }, |
| | | { |
| | | field: 'specification', |
| | | field: 'unitMeasureName', |
| | | title: '规格', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'itemTypeName', |
| | | field: 'specification', |
| | | title: '类别', |
| | | minWidth: 120, |
| | | }, |
| | |
| | | minWidth: 150, |
| | | }, |
| | | { |
| | | field: 'specification', |
| | | field: 'unitMeasureName', |
| | | title: '规格', |
| | | minWidth: 120, |
| | | }, |
| | | { |
| | | field: 'itemTypeName', |
| | | field: 'specification', |
| | | title: '类别', |
| | | minWidth: 120, |
| | | }, |