feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -0,0 +1,546 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
单据计算引擎
支持:
- 基本运算:+, -, *, /, %, **
- 聚合函数:sum, avg, max, min, count
- 内置函数:round, abs, ceil, floor, numberToChinese
- 条件表达式:if-else (三元运算符)
- 安全执行:使用 simpleeval 库防止代码注入
"""
import logging
import math
import re
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List, Optional, Union
logger = logging.getLogger(__name__)
# 中文数字映射
CHINESE_DIGITS = ['', '', '', '', '', '', '', '', '', '']
CHINESE_UNITS = ['', '', '', '']
CHINESE_GROUP_UNITS = ['', '', '亿', '']
CHINESE_DECIMAL_UNITS = ['', '', '', '']
def number_to_chinese(num: Union[int, float, Decimal, str]) -> str:
"""
将数字转换为中文大写金额
Args:
num: 数字(支持整数、浮点数、Decimal、字符串)
Returns:
中文大写金额字符串
Examples:
>>> number_to_chinese(1234.56)
'壹仟贰佰叁拾肆元伍角陆分'
>>> number_to_chinese(0)
'零元整'
"""
if num is None:
return ''
try:
# 转换为 Decimal 以保证精度
if isinstance(num, str):
num = Decimal(num.replace(',', ''))
elif isinstance(num, float):
num = Decimal(str(num))
elif isinstance(num, int):
num = Decimal(num)
elif not isinstance(num, Decimal):
num = Decimal(str(num))
except Exception:
return str(num)
# 处理负数
if num < 0:
return '' + number_to_chinese(-num)
# 处理零
if num == 0:
return '零元整'
# 分离整数和小数部分
num = num.quantize(Decimal('0.0001'), rounding=ROUND_HALF_UP)
str_num = str(num)
if '.' in str_num:
int_part, dec_part = str_num.split('.')
else:
int_part, dec_part = str_num, ''
result = ''
# 处理整数部分
if int_part and int(int_part) > 0:
int_part = int_part.lstrip('0') or '0'
length = len(int_part)
# 按4位分组处理
groups = []
while int_part:
groups.insert(0, int_part[-4:])
int_part = int_part[:-4]
for i, group in enumerate(groups):
group_result = ''
group = group.zfill(4)
for j, digit in enumerate(group):
d = int(digit)
unit_index = 3 - j
if d != 0:
group_result += CHINESE_DIGITS[d] + CHINESE_UNITS[unit_index]
else:
# 处理连续零
if group_result and not group_result.endswith(''):
group_result += ''
# 移除末尾的零
group_result = group_result.rstrip('')
if group_result:
group_unit_index = len(groups) - 1 - i
group_result += CHINESE_GROUP_UNITS[group_unit_index] if group_unit_index < len(CHINESE_GROUP_UNITS) else ''
result += group_result
result += ''
else:
result = '零元'
# 处理小数部分
if dec_part:
dec_part = dec_part[:4] # 最多4位小数
has_decimal = False
for i, digit in enumerate(dec_part):
d = int(digit)
if d != 0:
result += CHINESE_DIGITS[d] + CHINESE_DECIMAL_UNITS[i]
has_decimal = True
if not has_decimal:
result += ''
else:
result += ''
return result
def safe_get_value(data: Dict[str, Any], key: str, default: Any = 0) -> Any:
"""
安全地从字典中获取值,支持点号路径
Args:
data: 数据字典
key: 键名(支持点号路径,如 'order.total'
default: 默认值
Returns:
获取的值或默认值
"""
if not key:
return default
parts = key.split('.')
value = data
for part in parts:
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list):
# 支持数组索引
if part.lstrip('-').isdigit():
index = int(part)
if -len(value) <= index < len(value):
value = value[index]
else:
return default
else:
return default
else:
return default
if value is None:
return default
return value if value is not None else default
class CalculationEngine:
"""单据计算引擎"""
# 支持的聚合函数
AGGREGATE_FUNCTIONS = {
'sum': lambda values: sum(v for v in values if v is not None),
'avg': lambda values: sum(v for v in values if v is not None) / len([v for v in values if v is not None]) if values else 0,
'max': lambda values: max((v for v in values if v is not None), default=0),
'min': lambda values: min((v for v in values if v is not None), default=0),
'count': lambda values: len([v for v in values if v is not None]),
}
# 安全的内置函数
SAFE_FUNCTIONS = {
'abs': abs,
'round': round,
'ceil': math.ceil,
'floor': math.floor,
'max': max,
'min': min,
'sum': sum,
'len': len,
'float': float,
'int': int,
'str': str,
'numberToChinese': number_to_chinese,
'toChineseAmount': number_to_chinese,
}
# 安全的运算符
SAFE_OPERATORS = {
'+', '-', '*', '/', '//', '%', '**',
'==', '!=', '<', '>', '<=', '>=',
'and', 'or', 'not',
'(', ')', ',', '.',
}
@classmethod
def evaluate_formula(cls, formula: str, context: Dict[str, Any]) -> Any:
"""
安全地执行计算公式
Args:
formula: 计算公式,如 "quantity * unit_price * (1 - discount_rate)"
context: 上下文数据
Returns:
计算结果
"""
if not formula:
return None
try:
# 替换公式中的变量
evaluated_formula = cls._replace_variables(formula, context)
logger.info(f"公式: {formula} -> 替换后: {evaluated_formula}")
# 使用 eval 执行(在受限环境中)
# 注意:这里使用了安全的方式,只允许特定的函数和运算
result = cls._safe_eval(evaluated_formula, context)
logger.info(f"公式执行结果: {result}")
return result
except Exception as e:
logger.error(f"公式计算失败: {formula}, 错误: {e}", exc_info=True)
return None
@classmethod
def _replace_variables(cls, formula: str, context: Dict[str, Any]) -> str:
"""
替换公式中的变量为实际值
支持的变量格式:
- 简单变量:quantity, unit_price
- 点号路径:order.total, items[0].price
"""
# 匹配变量名(字母开头,可包含字母、数字、下划线、点号、方括号)
pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)\b'
def replace_var(match):
var_name = match.group(1)
# 跳过函数名
if var_name in cls.SAFE_FUNCTIONS:
return var_name
# 跳过 Python 关键字
if var_name in ('and', 'or', 'not', 'if', 'else', 'True', 'False', 'None'):
return var_name
# 获取变量值
value = safe_get_value(context, var_name, 0)
# 转换为字符串表示
if value is None:
return '0'
elif isinstance(value, str):
# 尝试转换为数字
try:
return str(float(value))
except ValueError:
return f'"{value}"'
elif isinstance(value, bool):
return str(value)
elif isinstance(value, (int, float, Decimal)):
return str(float(value))
else:
return '0'
return re.sub(pattern, replace_var, formula)
@classmethod
def _safe_eval(cls, expression: str, context: Dict[str, Any]) -> Any:
"""
安全地执行表达式
使用受限的 eval 环境,只允许特定的函数和运算
"""
# 构建安全的执行环境
safe_globals = {
'__builtins__': {},
**cls.SAFE_FUNCTIONS,
}
# 添加上下文数据
safe_locals = dict(context)
try:
result = eval(expression, safe_globals, safe_locals)
return result
except Exception as e:
logger.warning(f"表达式执行失败: {expression}, 错误: {e}")
raise
@classmethod
def calculate_aggregation(
cls,
data: Dict[str, Any],
source: str,
field: str,
function: str
) -> Any:
"""
计算聚合值
Args:
data: 数据字典
source: 数据源(子表名)
field: 聚合字段
function: 聚合函数名
Returns:
聚合结果
"""
logger.info(f"聚合计算开始: source={source}, field={field}, function={function}")
logger.info(f"数据中的顶层键: {list(data.keys())}")
# 获取子表数据
sub_table_data = None
# 1. 优先从 sub_tables 中查找(表单数据的标准结构)
if 'sub_tables' in data and isinstance(data['sub_tables'], dict):
sub_tables = data['sub_tables']
logger.info(f"sub_tables 中的键: {list(sub_tables.keys())}")
# 直接匹配
if source in sub_tables:
sub_table_data = sub_tables[source]
logger.info(f"从 sub_tables 中直接匹配到 ({source}): 找到 {len(sub_table_data) if isinstance(sub_table_data, list) else 0}")
else:
# 尝试模糊匹配(source 可能是字段名,sub_tables 的键可能是表名)
# 例如:source='product_details'sub_tables 键可能是 'fd_product_details' 或 'contract_product_details'
for key in sub_tables.keys():
if source in key or key in source or key.endswith(f'_{source}') or key.endswith(source):
sub_table_data = sub_tables[key]
logger.info(f"从 sub_tables 中模糊匹配到 ({source} -> {key}): 找到 {len(sub_table_data) if isinstance(sub_table_data, list) else 0}")
break
# 2. 如果 sub_tables 中没有,尝试直接从顶层获取
if not sub_table_data:
sub_table_data = safe_get_value(data, source, [])
logger.info(f"从顶层获取子表数据 ({source}): {type(sub_table_data)}")
if not isinstance(sub_table_data, list):
logger.warning(f"聚合数据源不是数组: {source}, 实际类型: {type(sub_table_data)}")
return 0
if len(sub_table_data) == 0:
logger.warning(f"聚合数据源为空数组: {source}")
return 0
# 提取字段值
values = []
for i, item in enumerate(sub_table_data):
if isinstance(item, dict):
logger.info(f"子表第{i}行的键: {list(item.keys())}")
value = safe_get_value(item, field, None)
logger.info(f"子表第{i}行的 {field} 值: {value}")
if value is not None:
try:
values.append(float(value))
except (ValueError, TypeError) as e:
logger.warning(f"无法转换为数字: {value}, 错误: {e}")
logger.info(f"提取到的数值列表: {values}")
# 执行聚合函数
agg_func = cls.AGGREGATE_FUNCTIONS.get(function.lower())
if not agg_func:
logger.warning(f"不支持的聚合函数: {function}")
return 0
try:
result = agg_func(values)
logger.info(f"聚合计算结果: {result}")
return result
except Exception as e:
logger.warning(f"聚合计算失败: {e}")
return 0
@classmethod
def format_value(
cls,
value: Any,
format_type: str = 'number',
decimal_places: int = 2
) -> Any:
"""
格式化计算结果
Args:
value: 原始值
format_type: 格式化类型 (number/money/percent/chinese)
decimal_places: 小数位数
Returns:
格式化后的值(字符串,保留指定小数位数)
"""
if value is None:
return None
try:
num_value = float(value)
except (ValueError, TypeError):
return value
if format_type == 'chinese':
return number_to_chinese(num_value)
elif format_type == 'percent':
# 百分比:乘以100后保留指定小数位,返回格式化字符串
percent_value = num_value * 100
if decimal_places == 0:
return str(int(round(percent_value)))
return f"{percent_value:.{decimal_places}f}"
else:
# number 和 money:保留指定小数位,返回格式化字符串
if decimal_places == 0:
return str(int(round(num_value)))
return f"{num_value:.{decimal_places}f}"
@classmethod
async def calculate_all(
cls,
calculation_rules: Optional[Dict[str, Any]],
form_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
执行所有计算规则
Args:
calculation_rules: 计算规则配置
form_data: 表单数据
Returns:
计算结果字典
"""
if not calculation_rules:
return {}
results = {}
# 创建计算上下文(包含原始数据和已计算的结果)
context = dict(form_data)
# 1. 先执行聚合计算(因为计算字段可能依赖聚合结果)
aggregations = calculation_rules.get('aggregations', [])
logger.info(f"开始执行聚合计算,共 {len(aggregations)}")
for agg in aggregations:
try:
logger.info(f"聚合配置原始数据: {agg}")
name = agg.get('name')
source = agg.get('source')
field = agg.get('field')
function = agg.get('function') or 'sum'
format_type = agg.get('format') or 'number'
# 确保 decimal_places 是整数,处理 None 和非数字情况
decimal_places_raw = agg.get('decimal_places')
decimal_places = int(decimal_places_raw) if decimal_places_raw is not None else 2
logger.info(f"聚合字段解析: name={name}, source={source}, field={field}, function={function}, format={format_type}, decimal_places={decimal_places}")
if not all([name, source, field]):
logger.warning(f"聚合字段配置不完整,跳过: name={name}, source={source}, field={field}")
continue
# 计算聚合值
raw_value = cls.calculate_aggregation(context, source, field, function)
logger.info(f"聚合原始值: {raw_value}, 类型: {type(raw_value)}")
# 格式化
formatted_value = cls.format_value(raw_value, format_type, decimal_places)
logger.info(f"格式化后: {formatted_value}, decimal_places={decimal_places}")
results[name] = formatted_value
context[name] = raw_value # 使用原始值用于后续计算
# 如果是中文格式,同时保存原始数值
if format_type == 'chinese':
results[f'{name}_raw'] = raw_value
logger.info(f"聚合计算完成: {name} = {formatted_value}")
except Exception as e:
logger.warning(f"聚合计算失败: {agg}, 错误: {e}")
# 2. 执行计算字段(按顺序,支持依赖)
fields = calculation_rules.get('fields', [])
logger.info(f"开始执行计算字段,共 {len(fields)}")
for field_config in fields:
try:
name = field_config.get('name')
formula = field_config.get('formula')
format_type = field_config.get('format') or 'number'
# 确保 decimal_places 是整数,处理 None 和非数字情况
decimal_places_raw = field_config.get('decimal_places')
decimal_places = int(decimal_places_raw) if decimal_places_raw is not None else 2
logger.info(f"处理计算字段: name={name}, formula={formula}, format={format_type}, decimal_places={decimal_places}")
if not all([name, formula]):
logger.warning(f"计算字段配置不完整,跳过: {field_config}")
continue
# 计算公式
raw_value = cls.evaluate_formula(formula, context)
logger.info(f"计算字段 {name} 原始值: {raw_value}")
if raw_value is not None:
# 格式化
formatted_value = cls.format_value(raw_value, format_type, decimal_places)
logger.info(f"计算字段 {name} 格式化后: {formatted_value}")
results[name] = formatted_value
context[name] = raw_value # 使用原始值用于后续计算
# 如果是中文格式,同时保存原始数值
if format_type == 'chinese':
results[f'{name}_raw'] = raw_value
logger.info(f"公式计算完成: {name} = {formatted_value}")
else:
logger.warning(f"计算字段 {name} 返回 None")
except Exception as e:
logger.error(f"公式计算失败: {field_config}, 错误: {e}", exc_info=True)
return results
# 导出
__all__ = ['CalculationEngine', 'number_to_chinese', 'safe_get_value']