Files

547 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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']