<script setup lang="ts">
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useAccess } from '@vben/access';
|
import { MesWmWarehouseTypeEnum } from '@vben/constants';
|
|
import {
|
approveSeedQualityBatch,
|
getSeedIndicatorRanges,
|
getSeedQualityPage,
|
resolveSeedQuality,
|
resolveSeedRetestScope,
|
submitSeedQualityGroup,
|
submitSeedRetestScope,
|
type SeedQualityRecord,
|
type SeedQualityScan,
|
type SeedRetestScope,
|
} from '#/api/mes/qc/seedQuality';
|
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
|
import { errorText } from '#/mobile/api';
|
import { extractCode } from '#/mobile/access';
|
|
import Scanner from './scanner.vue';
|
|
type ReviewGroup = { ids: number[]; stage: 'company' | 'process'; status: number };
|
|
const { hasAccessByCodes } = useAccess();
|
const canInitial = computed(() => hasAccessByCodes(['mes:qc-seed-quality:submit']));
|
const canRetest = computed(() => hasAccessByCodes(['mes:qc-seed-quality:retest']));
|
const canProcessReview = computed(() => hasAccessByCodes(['mes:qc-seed-quality:process-approve']));
|
const canCompanyReview = computed(() => hasAccessByCodes(['mes:qc-seed-quality:company-approve']));
|
const canBatchAction = computed(() => canInitial.value || canRetest.value || canProcessReview.value || canCompanyReview.value);
|
|
const rows = ref<SeedQualityRecord[]>([]), busy = ref(false), error = ref(''), notice = ref('');
|
const scanType = ref<'BATCH' | 'PALLET'>('PALLET'), scannedCode = ref('');
|
const scope = ref<SeedRetestScope>(), initialScope = ref<SeedQualityScan>();
|
const workflowStatus = ref<10 | 50>(10), reason = ref('');
|
const warehouseId = ref<number>();
|
const warehouseOptions = ref<Array<{ label: string; value: number }>>([]);
|
const reviewGroup = ref<ReviewGroup>(), reviewApproved = ref(true), reviewOpinion = ref('');
|
const indicators = ref([
|
{ code: 'purity', name: '纯度', value: '', minValue: '', maxValue: '' },
|
{ code: 'cleanliness', name: '净度', value: '', minValue: '', maxValue: '' },
|
{ code: 'germination_rate', name: '发芽率', value: '', minValue: '', maxValue: '' },
|
{ code: 'moisture', name: '水分', value: '', minValue: '', maxValue: '' },
|
]);
|
|
const initialIds = computed(() => initialScope.value?.pendingBagIds || []);
|
const targetIds = computed(() => workflowStatus.value === 10 ? scope.value?.retestIds || [] : scope.value?.initialRecheckIds || []);
|
const complete = computed(() => indicators.value.every((item) =>
|
[item.value, item.minValue, item.maxValue].every((value) => String(value).trim() !== '' && Number.isFinite(Number(value)))
|
&& Number(item.value) >= 0 && Number(item.value) <= 100 && Number(item.minValue) >= 0
|
&& Number(item.maxValue) <= 100 && Number(item.minValue) <= Number(item.maxValue)));
|
const passed = computed(() => complete.value && indicators.value.every((item) =>
|
Number(item.value) >= Number(item.minValue) && Number(item.value) <= Number(item.maxValue)));
|
const reviewGroups = computed<ReviewGroup[]>(() => {
|
if (!scope.value) return [];
|
return [
|
{ status: 20, stage: 'process' as const, ids: scope.value.pendingProcessIds || [] },
|
{ status: 25, stage: 'process' as const, ids: scope.value.pendingInitialFailIds || [] },
|
{ status: 30, stage: 'company' as const, ids: scope.value.pendingCompanyIds || [] },
|
{ status: 60, stage: 'company' as const, ids: scope.value.pendingCompanyFailIds || [] },
|
].filter((group) => group.ids.length && (group.stage === 'process' ? canProcessReview.value : canCompanyReview.value));
|
});
|
const statusNames: Record<number, string> = {
|
10: '待复检', 20: '待初检审核', 25: '待初检不合格处理', 30: '待复检审核',
|
40: '已放行', 50: '待重新初检', 60: '待复检不合格处理', 70: '已报废',
|
};
|
|
function resetIndicators() {
|
indicators.value.forEach((item) => { item.value = ''; });
|
reason.value = '';
|
}
|
|
async function hydrateReviewIds(current: SeedRetestScope, rawCode: string) {
|
const loaded = (current.pendingProcessIds?.length || 0) + (current.pendingInitialFailIds?.length || 0)
|
+ (current.pendingCompanyIds?.length || 0) + (current.pendingCompanyFailIds?.length || 0);
|
if (loaded >= current.pendingProcessCount + current.pendingCompanyCount) return;
|
const batch = current.batchCode || (current.scanType === 'BATCH' ? current.code : undefined);
|
if (!batch) return;
|
const statuses = [20, 25, 30, 60] as const;
|
const pages = await Promise.all(statuses.map((workflow) => getSeedQualityPage({
|
batchCode: batch, pageNo: 1, pageSize: 500, workflowStatus: workflow,
|
}).catch(() => ({ list: [] as SeedQualityRecord[], total: 0 }))));
|
const scopeCodes = new Set([current.code, rawCode]);
|
const ids = pages.map(({ list }) => list
|
.filter((row) => current.scanType === 'BATCH' || (!!row.palletCode && scopeCodes.has(row.palletCode)))
|
.map((row) => row.id));
|
current.pendingProcessIds = ids[0];
|
current.pendingInitialFailIds = ids[1];
|
current.pendingCompanyIds = ids[2];
|
current.pendingCompanyFailIds = ids[3];
|
}
|
|
async function identifyScope(raw: string) {
|
if (busy.value || !canBatchAction.value) return;
|
busy.value = true; error.value = ''; notice.value = '';
|
scope.value = undefined; initialScope.value = undefined; reviewGroup.value = undefined;
|
try {
|
const codeValue = extractCode(raw);
|
scannedCode.value = codeValue;
|
const current = await resolveSeedRetestScope(codeValue, scanType.value);
|
await hydrateReviewIds(current, codeValue);
|
scope.value = current;
|
scanType.value = current.scanType;
|
workflowStatus.value = current.retestIds.length ? 10 : 50;
|
initialScope.value = await resolveSeedQuality(codeValue, current.scanType);
|
resetIndicators();
|
if (!initialIds.value.length && !targetIds.value.length && !reviewGroups.value.length) {
|
notice.value = '当前托盘或生产批次没有可处理的质检记录。';
|
}
|
} catch (exception) { error.value = errorText(exception); }
|
finally { busy.value = false; }
|
}
|
|
function indicatorPayload() {
|
return indicators.value.map((item) => ({ code: item.code, value: Number(item.value), minValue: Number(item.minValue), maxValue: Number(item.maxValue) }));
|
}
|
|
async function refreshScopeAndList(success: string) {
|
await identifyScope(scannedCode.value);
|
await load();
|
notice.value = success;
|
}
|
|
async function submitInitial() {
|
const ids = initialIds.value.slice(0, 500);
|
if (busy.value || !canInitial.value || !initialScope.value || !ids.length || !complete.value) return;
|
if (!warehouseId.value) { error.value = '请选择检验合格后的目标合格库'; return; }
|
if (!passed.value && !reason.value.trim()) { error.value = '初检不合格请填写原因'; return; }
|
if (!window.confirm(`确认批量提交 ${ids.length} 袋初检?`)) return;
|
busy.value = true; error.value = '';
|
try {
|
const result = await submitSeedQualityGroup({ code: initialScope.value.code, batchCode: initialScope.value.batchCode,
|
scanType: initialScope.value.scanType || scanType.value, bagIds: ids, qualifiedWarehouseId: warehouseId.value,
|
reason: reason.value.trim() || undefined, indicators: indicatorPayload() });
|
busy.value = false;
|
await refreshScopeAndList(`已完成 ${result.processedCount} 袋初检。`);
|
} catch (exception) { error.value = errorText(exception) + '。请重新识别后核对待检清单。'; }
|
finally { busy.value = false; }
|
}
|
|
async function submitRetest() {
|
const ids = targetIds.value.slice(0, 500);
|
if (busy.value || !canRetest.value || !scope.value || !ids.length || !complete.value) return;
|
if (!passed.value && !reason.value.trim()) { error.value = '检验不合格请填写原因'; return; }
|
if (!window.confirm(`确认将本次指标用于 ${ids.length} 袋${workflowStatus.value === 10 ? '复检' : '重新初检'}?`)) return;
|
busy.value = true; error.value = '';
|
try {
|
const result = await submitSeedRetestScope({ code: scope.value.code, scanType: scope.value.scanType,
|
workflowStatus: workflowStatus.value, qualityIds: ids, reason: reason.value.trim() || undefined, indicators: indicatorPayload() });
|
busy.value = false;
|
await refreshScopeAndList(`已完成 ${result.processedCount} 袋检验。`);
|
} catch (exception) { error.value = errorText(exception) + '。请重新识别后核对待检清单。'; }
|
finally { busy.value = false; }
|
}
|
|
function selectReview(group: ReviewGroup) {
|
reviewGroup.value = group; reviewApproved.value = true; reviewOpinion.value = ''; error.value = '';
|
}
|
|
async function submitReview() {
|
if (busy.value || !reviewGroup.value?.ids.length) return;
|
if (!reviewApproved.value && !reviewOpinion.value.trim()) { error.value = '驳回时请填写审核意见'; return; }
|
const ids = reviewGroup.value.ids.slice(0, 500);
|
if (!window.confirm(`确认${reviewApproved.value ? '通过' : '驳回'}本次 ${ids.length} 条记录?`)) return;
|
busy.value = true; error.value = '';
|
try {
|
await approveSeedQualityBatch(reviewGroup.value.stage, { ids, approved: reviewApproved.value, opinion: reviewOpinion.value.trim() });
|
busy.value = false;
|
await refreshScopeAndList(`已完成 ${ids.length} 条审核。`);
|
} catch (exception) { error.value = errorText(exception) + '。请重新识别后核对待审核清单。'; }
|
finally { busy.value = false; }
|
}
|
|
const code = ref(''), batchCode = ref(''), result = ref(''), page = ref(0), total = ref(0);
|
let filters: { batchCode?: string; code?: string; result?: number } = {};
|
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, batchCode: batchCode.value.trim() || undefined,
|
result: result.value ? Number(result.value) : undefined } : filters;
|
try {
|
const data = await getSeedQualityPage({ ...query, pageNo: next, pageSize: 20 });
|
rows.value = reset ? data.list : [...rows.value, ...data.list];
|
page.value = next; total.value = data.total; filters = query;
|
} catch (exception) { error.value = errorText(exception); }
|
finally { busy.value = false; }
|
}
|
|
onMounted(async () => {
|
if (canInitial.value) {
|
try {
|
const warehouses = await getWarehouseSimpleList();
|
warehouseOptions.value = warehouses.filter((item) => item.type === MesWmWarehouseTypeEnum.QUALIFIED && !item.frozen && item.id)
|
.map((item) => ({ label: item.code ? `${item.name}(${item.code})` : item.name || '', value: item.id! }));
|
if (warehouseOptions.value.length === 1) warehouseId.value = warehouseOptions.value[0]?.value;
|
} catch { warehouseOptions.value = []; }
|
}
|
if (canInitial.value || canRetest.value) {
|
try {
|
const ranges = await getSeedIndicatorRanges();
|
indicators.value.forEach((item) => { const range = ranges.find((row) => row.indicatorCode === item.code);
|
if (range) { item.minValue = String(range.minValue); item.maxValue = String(range.maxValue); } });
|
} catch { /* 合格范围仍可手工填写 */ }
|
}
|
await load();
|
});
|
</script>
|
|
<template>
|
<section v-if="canBatchAction" class="m-panel">
|
<h2 class="m-title">批量检验与审核</h2>
|
<p class="m-muted">扫描托盘码或生产批次码,系统会按当前账号权限列出批量初检、审核、复检操作。</p>
|
<label class="m-field"><span>扫码类型</span><select v-model="scanType" :disabled="busy" @change="scope = undefined; initialScope = undefined"><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label>
|
<Scanner :disabled="busy" @code="identifyScope" />
|
|
<template v-if="scope">
|
<div class="m-panel"><h3 class="m-title">已识别范围</h3><dl><div class="m-row"><dt>码值</dt><dd>{{ scope.code }}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{ scope.batchCode || '—' }}</dd></div><div class="m-row"><dt>有效袋数</dt><dd>{{ scope.totalCount }}</dd></div></dl></div>
|
|
<form v-if="canInitial && initialIds.length" class="m-panel" @submit.prevent="submitInitial">
|
<h3 class="m-title">批量初检({{ Math.min(initialIds.length, 500) }} 袋)</h3>
|
<p v-if="!initialScope?.inbound" class="m-warning">该范围的有效袋码尚未全部入库,暂不能提交初检。</p>
|
<template v-else>
|
<label class="m-field"><span>目标合格库</span><select v-model="warehouseId" required :disabled="busy"><option :value="undefined" disabled>请选择合格库</option><option v-for="item in warehouseOptions" :key="item.value" :value="item.value">{{ item.label }}</option></select></label>
|
<fieldset v-for="item in indicators" :key="`initial-${item.code}`" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{ item.name }}(%)</legend><label class="m-field"><span>实测值</span><input v-model="item.value" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="item.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="item.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div></fieldset>
|
<label class="m-field"><span>{{ complete && !passed ? '不合格原因(必填)' : '检验备注' }}</span><textarea v-model="reason" rows="3" maxlength="500" /></label>
|
<button class="m-primary" :disabled="busy || !complete || !warehouseId">{{ busy ? '正在提交…' : `提交 ${Math.min(initialIds.length, 500)} 袋初检` }}</button>
|
</template>
|
</form>
|
|
<section v-if="reviewGroups.length" class="m-panel">
|
<h3 class="m-title">批量审核</h3>
|
<button v-for="group in reviewGroups" :key="group.status" type="button" class="m-secondary" :disabled="busy" @click="selectReview(group)">{{ statusNames[group.status] }} · {{ Math.min(group.ids.length, 500) }} 条</button>
|
<form v-if="reviewGroup" @submit.prevent="submitReview">
|
<label class="m-field"><span>审核结论</span><select v-model="reviewApproved" :disabled="busy"><option :value="true">通过 / 确认处理</option><option :value="false">驳回重新检验</option></select></label>
|
<label class="m-field"><span>{{ reviewApproved ? '审核意见(选填)' : '驳回意见(必填)' }}</span><textarea v-model="reviewOpinion" rows="3" maxlength="500" /></label>
|
<button class="m-primary" :disabled="busy">{{ busy ? '正在审核…' : `确认审核 ${Math.min(reviewGroup.ids.length, 500)} 条` }}</button>
|
</form>
|
</section>
|
|
<form v-if="canRetest && (scope.retestIds.length || scope.initialRecheckIds.length)" class="m-panel" @submit.prevent="submitRetest">
|
<h3 class="m-title">批量复检 / 重新初检</h3>
|
<label class="m-field"><span>质检阶段</span><select v-model="workflowStatus" :disabled="busy"><option v-if="scope.retestIds.length" :value="10">复检({{ scope.retestIds.length }} 袋)</option><option v-if="scope.initialRecheckIds.length" :value="50">重新初检({{ scope.initialRecheckIds.length }} 袋)</option></select></label>
|
<p class="m-warning">同一组指标将用于本次 {{ Math.min(targetIds.length, 500) }} 袋,请核对抽样结果。</p>
|
<fieldset v-for="item in indicators" :key="`retest-${item.code}`" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{ item.name }}(%)</legend><label class="m-field"><span>实测值</span><input v-model="item.value" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="item.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="item.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div></fieldset>
|
<label class="m-field"><span>{{ complete && !passed ? '不合格原因(必填)' : '检验备注' }}</span><textarea v-model="reason" rows="3" maxlength="500" /></label>
|
<button class="m-primary" :disabled="busy || !complete || !targetIds.length">{{ busy ? '正在提交…' : `提交 ${Math.min(targetIds.length, 500)} 袋检验` }}</button>
|
</form>
|
</template>
|
</section>
|
<section v-else class="m-panel"><h2 class="m-title">批量检验与审核</h2><p class="m-muted">当前账号只有质检记录查看权限。如需批量操作,请联系管理员配置初检、复检或审核权限。</p></section>
|
|
<p v-if="notice" class="m-success" role="status">{{ notice }}</p>
|
<p v-if="error" class="m-error" role="alert">{{ error }}</p>
|
<p class="m-muted">查看质检结果、批次与不合格原因。</p>
|
<Scanner :disabled="busy" @code="value => { code = value; load(); }" />
|
<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><input v-model="batchCode" :disabled="busy" placeholder="输入批次号" /></label>
|
<label class="m-field"><span>质检结果</span><select v-model="result" :disabled="busy"><option value="">全部结果</option><option value="1">合格</option><option value="2">不合格</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>
|
<article v-for="row in rows" :key="row.id" class="m-panel">
|
<strong :class="['m-status', row.result === 1 ? 'success' : row.result === 2 ? 'failed' : '']">{{ row.result === 1 ? '合格' : row.result === 2 ? '不合格' : '处理中' }}</strong>
|
<dl><div class="m-row"><dt>追溯码</dt><dd>{{ row.code }}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{ row.batchCode || '—' }}</dd></div><div class="m-row"><dt>流程状态</dt><dd>{{ statusNames[row.workflowStatus] || '未知状态' }}</dd></div><div class="m-row"><dt>检验时间</dt><dd>{{ row.inspectedAt || row.createTime || '—' }}</dd></div><div v-if="row.reason" class="m-row"><dt>原因 / 备注</dt><dd>{{ row.reason }}</dd></div></dl>
|
</article>
|
<button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{ busy ? '正在加载…' : '加载更多' }}</button>
|
</template>
|