/**
|
* 报告判定
|
* <p>
|
* 在渲染前把检验项算成 PASS/FAIL,并汇总出报告结论与合格率。
|
* 判定只发生在这里,组件与模板都不做计算,保证同一份数据在任何模板下判定一致。
|
*/
|
import type { QualityResult, ReportContext, ReportContextItem } from './context';
|
import type { RuleNode } from './rule-engine';
|
|
import { QUALITY_RESULT, QUALITY_RESULT_TEXT } from './context';
|
import { toNumber } from './path';
|
import {
|
evaluateConditionNode,
|
findUnknownFunction,
|
listAllFunctionNames,
|
parseRule,
|
RuleSyntaxError,
|
} from './rule-engine';
|
|
/** 规则作用域:item 逐项判定,report 报告级汇总 */
|
export type QualityRuleScope = 'item' | 'report';
|
|
export interface QualityRuleDefinition {
|
/** 规则标识,用于出错时定位 */
|
id?: string;
|
/** 规则名称 */
|
name?: string;
|
scope: QualityRuleScope;
|
/** 判定表达式,求值为真视为合格 */
|
expression: string;
|
/** 该规则是否参与判定,默认 true */
|
enabled?: boolean;
|
}
|
|
/** 待判定标记:规则本身出错时用,避免把不确定当成合格 */
|
const PENDING_TEXT = '待判定';
|
|
/**
|
* 无判定规则标记:既没有规则、也没有规格上下限。
|
* <p>
|
* 和「待判定」刻意分开:待判定是「本该判、但规则跑挂了」,仍留在合格率分母里;
|
* 无判定规则是「这项根本没有判定依据」,不计入合格率分母、也不参与报告结论。
|
*/
|
const UNDECIDABLE_TEXT = '无判定规则';
|
|
/**
|
* 校验入参:只用到这三个字段,且都允许缺省。
|
* <p>
|
* 不复用 {@link QualityRuleDefinition}——那里的 expression 是必填的,
|
* 而设计器里正在编辑的规则、以及历史数据里的规则项都可能是空的,
|
* 校验函数恰恰要能接住这种「还没写完」的入参。
|
*/
|
export interface RuleValidationInput {
|
id?: string;
|
name?: string;
|
expression?: string;
|
}
|
|
/**
|
* 保存前校验规则文本:语法与函数白名单。
|
* <p>
|
* 两块都要拦。只拦语法的话,{@code FOO(1)} 这种调用会一路存进库里,
|
* 之后每张报告都带一条「无法执行」,而用户在设计器里看不出哪里不对。
|
* 返回的问题清单与后端 {@code QualityReportEngine.validateRules} 同口径。
|
*/
|
export function validateRule(rule: RuleValidationInput): string[] {
|
const errors: string[] = [];
|
const label = rule.name?.trim() || rule.id || '未命名规则';
|
if (!rule.expression?.trim()) {
|
errors.push(`规则「${label}」的表达式为空`);
|
return errors;
|
}
|
|
let node: RuleNode;
|
try {
|
node = parseRule(rule.expression);
|
} catch (error) {
|
if (error instanceof RuleSyntaxError) {
|
errors.push(`规则「${label}」语法错误:${error.message}(第 ${error.position + 1} 个字符)`);
|
} else {
|
errors.push(`规则「${label}」无法解析:${(error as Error).message}`);
|
}
|
return errors;
|
}
|
|
const unknown = findUnknownFunction(node);
|
if (unknown) {
|
errors.push(
|
`规则「${label}」调用了不支持的函数 ${unknown},可用函数:${listAllFunctionNames().join('、')}`,
|
);
|
}
|
return errors;
|
}
|
|
/** 用规格上下限判定单项:实测值不在区间内即不合格 */
|
function evaluateByLimit(item: ReportContextItem): boolean | undefined {
|
const actual = toNumber(item.actualValue);
|
if (!Number.isFinite(actual)) {
|
return undefined;
|
}
|
const hasUpper = typeof item.upperLimit === 'number' && Number.isFinite(item.upperLimit);
|
const hasLower = typeof item.lowerLimit === 'number' && Number.isFinite(item.lowerLimit);
|
if (!hasUpper && !hasLower) {
|
return undefined;
|
}
|
if (hasUpper && actual > item.upperLimit!) {
|
return false;
|
}
|
if (hasLower && actual < item.lowerLimit!) {
|
return false;
|
}
|
return true;
|
}
|
|
function normalizeExisting(result: string): QualityResult | undefined {
|
const text = result?.trim().toUpperCase();
|
if (text === QUALITY_RESULT.PASS) {
|
return QUALITY_RESULT.PASS;
|
}
|
if (text === QUALITY_RESULT.FAIL) {
|
return QUALITY_RESULT.FAIL;
|
}
|
return undefined;
|
}
|
|
function applyResult(item: ReportContextItem, result: QualityResult | undefined): void {
|
if (!result) {
|
item.result = '';
|
item.resultText = PENDING_TEXT;
|
return;
|
}
|
item.result = result;
|
item.resultText = QUALITY_RESULT_TEXT[result];
|
}
|
|
/**
|
* 没有判定依据:置为「无判定规则」。
|
* <p>
|
* 报告里常有「试样质量 m」「称量瓶 m0」这类只供公式取数的过程参数,它们没有规格也就无从判定;
|
* 若照常算进分母,一份本来全合格的报告会因为几个过程参数被压成「待判定」。
|
*/
|
function applyUndecidable(item: ReportContextItem): void {
|
item.result = '';
|
item.resultText = UNDECIDABLE_TEXT;
|
}
|
|
function formatPassRate(passCount: number, total: number): string {
|
if (total === 0) {
|
return '';
|
}
|
return `${((passCount / total) * 100).toFixed(2)}%`;
|
}
|
|
export interface EvaluationOutcome {
|
context: ReportContext;
|
/** 规则的问题清单,渲染时应在报告上显式暴露,不能悄悄吞掉 */
|
errors: string[];
|
}
|
|
/**
|
* 计算检验项判定与报告汇总。
|
* <p>
|
* 单个检验项的判定优先级:item 作用域规则 → 规格上下限 → 数据自带结果。
|
* 报告级判定默认取「全部检验项合格」,有 report 作用域规则时以规则为准。
|
*/
|
export function evaluateReport(
|
context: ReportContext,
|
rules: QualityRuleDefinition[] = [],
|
): EvaluationOutcome {
|
const errors: string[] = [];
|
const itemRules = rules.filter((rule) => rule.scope === 'item' && rule.enabled !== false);
|
const reportRules = rules.filter((rule) => rule.scope === 'report' && rule.enabled !== false);
|
|
const items = context.inspectionItems.map((item) => ({ ...item }));
|
/** 无判定依据的项数,只用来把这类项从合格率分母与报告结论里摘出去 */
|
let undecidableCount = 0;
|
|
items.forEach((item, index) => {
|
if (itemRules.length > 0) {
|
let passed = true;
|
let failed = false;
|
itemRules.forEach((rule) => {
|
// 逐项规则在「单项上下文」里求值,item 指向当前行
|
const scoped = { ...context, report: context.report, item, index: index + 1 };
|
try {
|
if (!evaluateConditionNode(compiledRule(rule.expression), scoped)) {
|
passed = false;
|
}
|
} catch (error) {
|
failed = true;
|
errors.push(
|
`第 ${index + 1} 项「${item.itemName}」的规则「${rule.name ?? rule.id ?? ''}」无法执行:${(error as Error).message}`,
|
);
|
}
|
});
|
applyResult(item, failed ? undefined : passed ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL);
|
return;
|
}
|
|
const byLimit = evaluateByLimit(item);
|
if (byLimit !== undefined) {
|
applyResult(item, byLimit ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL);
|
return;
|
}
|
|
const existing = normalizeExisting(item.result);
|
if (!existing) {
|
// 既没有规则、也没有判定依据:如实标出来,不要默认合格
|
applyUndecidable(item);
|
undecidableCount++;
|
errors.push(
|
`第 ${index + 1} 项「${item.itemName || '未命名'}」没有规格上下限也没有判定规则,无法判定`,
|
);
|
return;
|
}
|
applyResult(item, existing);
|
});
|
|
const passCount = items.filter((item) => item.result === QUALITY_RESULT.PASS).length;
|
const failCount = items.filter((item) => item.result === QUALITY_RESULT.FAIL).length;
|
const total = items.length;
|
/** 合格率的分母只算「能判定」的项,无判定规则的项既不加分也不减分 */
|
const decidableCount = total - undecidableCount;
|
|
let reportResult: QualityResult | undefined;
|
if (reportRules.length > 0) {
|
let passed = true;
|
let failed = false;
|
reportRules.forEach((rule) => {
|
const scoped = { ...context, inspectionItems: items, index: 0 };
|
try {
|
if (!evaluateConditionNode(compiledRule(rule.expression), scoped)) {
|
passed = false;
|
}
|
} catch (error) {
|
failed = true;
|
errors.push(`报告级规则「${rule.name ?? rule.id ?? ''}」无法执行:${(error as Error).message}`);
|
}
|
});
|
reportResult = failed ? undefined : passed ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL;
|
} else if (decidableCount > 0) {
|
// 只有「全部可判定项都合格」才给合格结论。无判定规则的项不参与,它们没有对错可言。
|
// 存在待判定项(规则跑挂)时不给结论:没验过不能说合格,没证据也不能说不合格,
|
// 否则会出现「结论合格、合格率 0%」这种自相矛盾的报告。
|
if (failCount > 0) {
|
reportResult = QUALITY_RESULT.FAIL;
|
} else if (passCount === decidableCount) {
|
reportResult = QUALITY_RESULT.PASS;
|
}
|
}
|
|
const report = {
|
...context.report,
|
total,
|
passCount,
|
failCount,
|
passRate: formatPassRate(passCount, decidableCount),
|
result: reportResult ?? '',
|
resultText: reportResult ? QUALITY_RESULT_TEXT[reportResult] : PENDING_TEXT,
|
conclusion: reportResult ? QUALITY_RESULT_TEXT[reportResult] : PENDING_TEXT,
|
};
|
|
return { context: { report, inspectionItems: items }, errors };
|
}
|
|
/** 校验过的规则只解析一次,多行复用时直接对语法树求值 */
|
const ruleCache = new Map<string, RuleNode>();
|
|
function compiledRule(expression: string): RuleNode {
|
const cached = ruleCache.get(expression);
|
if (cached) {
|
return cached;
|
}
|
const node = parseRule(expression);
|
ruleCache.set(expression, node);
|
return node;
|
}
|