From 0ed998d086cec3b594bdf64d87fd0c5d8d256d17 Mon Sep 17 00:00:00 2001 From: marslbr Date: Wed, 29 Oct 2025 12:52:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9B=AE=E5=BD=95=E5=92=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reports/persona_report/circular_age.html | 207 ++ reports/persona_report/circular_gender.html | 183 ++ reports/persona_report/circular_genres.html | 207 ++ .../persona_report/circular_occupation.html | 207 ++ reports/persona_report/circular_zip.html | 207 ++ .../scorecard_report/business_evaluation.html | 191 ++ .../scorecard_report/chart_variable_woe.html | 119 + utils/certifications.json | 1 + utils/client.py | 992 ++++++++ 任务调度服务器/dagster.yaml | 16 + 普康健康审核机器人/cognition.py | 2039 +++++++++++++++++ 11 files changed, 4369 insertions(+) create mode 100644 reports/persona_report/circular_age.html create mode 100644 reports/persona_report/circular_gender.html create mode 100644 reports/persona_report/circular_genres.html create mode 100644 reports/persona_report/circular_occupation.html create mode 100644 reports/persona_report/circular_zip.html create mode 100644 reports/scorecard_report/business_evaluation.html create mode 100644 reports/scorecard_report/chart_variable_woe.html create mode 100644 utils/certifications.json create mode 100644 utils/client.py create mode 100644 任务调度服务器/dagster.yaml create mode 100644 普康健康审核机器人/cognition.py diff --git a/reports/persona_report/circular_age.html b/reports/persona_report/circular_age.html new file mode 100644 index 0000000..74147a4 --- /dev/null +++ b/reports/persona_report/circular_age.html @@ -0,0 +1,207 @@ + + + + + Awesome-pyecharts + + + + +
+ + + diff --git a/reports/persona_report/circular_gender.html b/reports/persona_report/circular_gender.html new file mode 100644 index 0000000..68d99e5 --- /dev/null +++ b/reports/persona_report/circular_gender.html @@ -0,0 +1,183 @@ + + + + + Awesome-pyecharts + + + + +
+ + + diff --git a/reports/persona_report/circular_genres.html b/reports/persona_report/circular_genres.html new file mode 100644 index 0000000..bef770d --- /dev/null +++ b/reports/persona_report/circular_genres.html @@ -0,0 +1,207 @@ + + + + + Awesome-pyecharts + + + + +
+ + + diff --git a/reports/persona_report/circular_occupation.html b/reports/persona_report/circular_occupation.html new file mode 100644 index 0000000..56e408b --- /dev/null +++ b/reports/persona_report/circular_occupation.html @@ -0,0 +1,207 @@ + + + + + Awesome-pyecharts + + + + +
+ + + diff --git a/reports/persona_report/circular_zip.html b/reports/persona_report/circular_zip.html new file mode 100644 index 0000000..53f9c93 --- /dev/null +++ b/reports/persona_report/circular_zip.html @@ -0,0 +1,207 @@ + + + + + Awesome-pyecharts + + + + +
+ + + diff --git a/reports/scorecard_report/business_evaluation.html b/reports/scorecard_report/business_evaluation.html new file mode 100644 index 0000000..87f90dc --- /dev/null +++ b/reports/scorecard_report/business_evaluation.html @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
分箱拒绝规则分箱逾期率拒绝率拒绝逾期率累计逾期率
[0, 350)<35048.964.2148.9630.72
[350, 400)<40031.507.0941.8644.26
[400, 450)<45020.0411.0933.9956.22
[450, 500)<50010.4722.2922.1773.68
[500, 550)<5505.6136.5315.7285.59
[550, 600)<6002.6956.7411.0893.71
[600, 650)<6501.1686.097.6998.76
[650, 1000)<10000.60100.006.71100.00
+ + + + + + \ No newline at end of file diff --git a/reports/scorecard_report/chart_variable_woe.html b/reports/scorecard_report/chart_variable_woe.html new file mode 100644 index 0000000..3b35fff --- /dev/null +++ b/reports/scorecard_report/chart_variable_woe.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
分箱逾期样本数还款样本数WOE
[0.0, 0.1)123066078-1.36
[0.1, 0.2)47415366-0.78
[0.2, 0.5)125323479-0.27
[0.5, 0.8)1585137590.47
[0.8, inf)5466205201.29
+ + + + + + \ No newline at end of file diff --git a/utils/certifications.json b/utils/certifications.json new file mode 100644 index 0000000..4158d3d --- /dev/null +++ b/utils/certifications.json @@ -0,0 +1 @@ +{"szkt": {"token": "a62c56ace614a6546191d5af8ca8b1513cfaeaea7ce67d0a37de994ab6c2aa4e2a0b058e0da575ff376dd51dc19c5ad353ab2761cb6d9db4d521b83adeee2979b78f7ae70765b26985165b6266d084b75f2f918008966e72a116d8bca5ec4c7cecc5223f78fa47b4d40aa9cf5277a11b0b967ad06e84ef7c4acbc53ccdef936c062b2d037ae0dad8c29d50426b668ec349cc8c0099a0270e16f97d31e4f058bc086334468f88d934c7fd1464ed3800833d2f486dc06f0689b99abbb78a8ebf4a3877bd82d0dd765dc09b7a1594fa8849d51f59282a81048c52e82e8320d1ad042a6c307ca831647cba4356564704780f", "expired_timestamp": 1759201579.393386}} \ No newline at end of file diff --git a/utils/client.py b/utils/client.py new file mode 100644 index 0000000..4e87804 --- /dev/null +++ b/utils/client.py @@ -0,0 +1,992 @@ +# -*- coding: utf-8 -*- + +# 导入模块 + +import hashlib +import hmac +import json +import re +import sqlite3 +import threading +import time +from email.parser import BytesParser +from email.policy import default +from email.utils import parsedate_to_datetime +from functools import wraps +from imaplib import IMAP4_SSL +from pathlib import Path +from typing import Any, Dict, Generator, Literal, Optional, Tuple, Union +from urllib.parse import quote_plus +from xml.etree import ElementTree + +import pandas +from pydantic import BaseModel, Field, HttpUrl, model_validator +from requests import Response, Session +from requests.adapters import HTTPAdapter +from sqlalchemy import create_engine, text +from urllib3.util.retry import Retry + + +""" +封装sqlalchemy,实现按照SQL查询 +类和函数的区别 +类作为对象的模板,定义了对象的结构和行为;而函数则作为实现特定功能或操作的代码块,提高了代码的可读性和可维护性。 +使用方法: +with MySQLClient(database='data_analysis') as client: + dataframe = client.execute_query(sql='select * from {{}}')') +""" + + +class MySQLClient: + """MySQL客户端""" + + def __init__( + self, + database, + host: str = "cdb-7z9lzx4y.cd.tencentcdb.com", + port: int = 10039, + username: str = "root", + password: str = "Te198752", + ): # 默认为刘弼仁的腾讯云MySQL数据库 + + # 数据库登录密码安全编码 + password = quote_plus(password) + + # 构建数据库连接字符 + connect_config = f"mysql+pymysql://{username}:{password}@{host}:{port}/{database}?charset=utf8" + + # 创建MySQL引擎并连接数据库 + self.engine = create_engine( + connect_config, + pool_size=5, + max_overflow=10, + pool_recycle=3600, + pool_pre_ping=True, + ) # 连接池中保持打开连接的数量为5,额外连接数为10,连接1小时后重新回收重连,检查连接有效性 + + def __del__(self): + """析构时自动关闭连接""" + + if hasattr(self, "engine") and self.engine: + self.engine.dispose() + + def execute_query(self, sql: str) -> pandas.DataFrame: + """执行SQL查询并返回DATAFRAME""" + + if not hasattr(self, "engine") or not self.engine: + raise ConnectionError("未创建数据库连接") + + try: + with self.engine.connect() as connection: + dataframe = pandas.read_sql_query( + text(sql), connection, coerce_float=False + ) # 不尝试将非整数数值转为浮点(维持DECIMAL) + return dataframe + + except: + connection.rollback() + raise RuntimeError("执行SQL查询并返回DATAFRAME发生其它异常") + + +""" +封装urllib.request的相关操作,实现常用HTTPREQUEST +使用方法: +client = HTTPClient() +response = clinet.post(url) +""" + + +class TokenBucket: + + def __init__(self, refill_rate, max_tokens): + """令牌桶,基于令牌桶算法限制请求频率""" + + # 填充令牌速率(个/秒) + self.refill_rate = refill_rate + # 令牌桶最大令牌数 + self.max_tokens = max_tokens + # 令牌桶当前令牌数 + self.tokens = max_tokens + # 上一次填充令牌时间戳(使用单调递增时间,单位为秒) + self.refill_timestamp = time.monotonic() + + # 获取令牌 + # noinspection PyMissingReturnStatement + def acquire(self) -> tuple[bool, float]: + + with threading.Lock(): + # 本次填充令牌时间戳 + refill_timestamp = time.monotonic() + + # 重新计算令牌桶中令牌数 + self.tokens = min( + self.max_tokens, + self.tokens + + self.refill_rate * (refill_timestamp - self.refill_timestamp), + ) + + self.refill_timestamp = refill_timestamp + + # 若令牌桶当前令牌数大于等于1则减少令牌 + if self.tokens >= 1: + self.tokens -= 1 + return True, 0.0 + + # 同时返回等待时间 + return False, 0.2 + + +# 将令牌桶以装饰函数封装为请求频率限制方法 +def restrict(refill_rate=5, max_tokens=5): + + def decorator(func): + + # 初始化令牌桶 + token_bucket = TokenBucket(refill_rate=refill_rate, max_tokens=max_tokens) + + @wraps(func) + def wrapper(*args, **kwargs): + + # 重试次数 + retries = 0 + + # 若重试数小于等于最大重试次数,则循环检查是否允许请求 + while retries <= 10: + + success, wait_time = token_bucket.acquire() + + # 若允许请求则返回嵌套函数,若不允许请求则等待 + if success: + + return func(*args, **kwargs) + + time.sleep(wait_time * 1.5**retries) + + retries += 1 + + raise Exception("request too frequently") + + return wrapper + + return decorator + + +class RequestException(Exception): + """请求异常""" + + def __init__( + self, status: int = 400, code: int = 0, message: str = "request failed" + ): + """ + :param status: 状态编码,默认为0 + :param message: 错误信息,默认为RequestException + """ + self.status = status + self.code = code + self.message = message + super().__init__(self.message) + + def __str__(self): + return f"请求发生异常({self.status}, {self.message})" + + +# 请求参数数据模型 +class Arguments(BaseModel): + """ + :param url: 统一资源定位符,基于统一资源定位符校验器进行校验 + :param params: 查询参数 + :param headers: 请求头 + :param data: 表单数据 + :param json_data: JSON # 入参时使用别名,出参时根据BY_ALIAS=TRUE确定是否使用别名 + :param files: 上传文件 + :param stream: 是否启用流式传输 + :param guid: 全局唯一标识 + """ + + # 统一资源定位符 + url: HttpUrl = Field(default=...) + # 查询参数 + params: Optional[Dict] = Field(default=None) + # 请求头 + headers: Optional[Dict] = Field(default=None) + # 表单数据 + data: Optional[Dict] = Field(default=None) + # JSON + json_data: Optional[Dict] = Field(default=None, alias="json") + # 上传文件 + files: Optional[ + Dict[ + str, + Union[ + Tuple[str, bytes], Tuple[str, bytes, str], Tuple[str, bytes, str, dict] + ], + ] + ] = Field(default=None) + # 是否启用流式传输 + stream: Optional[bool] = Field(default=None) + # 全局唯一标识 + guid: Optional[str] = Field(default=None) + + # 表单数据和JSON数据互斥 + @model_validator(mode="after") + def validate_data(self): + if self.data and self.json_data: + raise ValueError("cannot use both data and json parameters simultaneously") + return self + + # 上传文件和启用流式传输互斥 + @model_validator(mode="after") + def validate_files(self): + if self.files and self.stream: + raise ValueError( + "cannot use both files and stream parameters simultaneously" + ) + return self + + +# HTTP客户端 +class HTTPClient: + + def __init__( + self, + timeout: int = 60, + default_headers: Optional[Dict[str, str]] = None, + total: int = 3, + backoff_factor: float = 0.5, + cache_enabled: bool = False, + cache_ttl: int = 90, + ): + """ + :param timeout: 超时时间,单位为秒 + :param default_headers: 默认请求头 + :param total: 最大重试次数 + :param backoff_factor: 重试间隔退避因子 + :param cache_enabled: 是否使用缓存 + :param cache_ttl: 缓存生存时间,单位为天 + """ + + # 超时时间 + self.timeout = timeout + # 创建HTTP会话并挂载适配器 + self.session = self._create_session( + default_headers=default_headers, total=total, backoff_factor=backoff_factor + ) + + # 是否使用缓存 + self.cache_enabled = cache_enabled + # 缓存生存时间 + self.cache_ttl = cache_ttl + # 若使用缓存,则初始化缓存数据库 + if self.cache_enabled: + self._initialize_cache_database() + + # 创建HTTP会话并挂载适配器 + @staticmethod + def _create_session( + total: int, + backoff_factor: float, + default_headers: Optional[Dict[str, str]] = None, + ) -> Session: + """ + :param default_headers 默认请求头 + :param total 最大重试次数 + :param backoff_factor 重试间隔退避因子 + """ + + # 创建会话对象 + session = Session() + + # 设置请求头 + if default_headers: + session.headers.update(default_headers) + + # 设置重试策略(优先按照服响应等待时长,若未返回则默认按照退避算法等待) + strategy_retries = Retry( + allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "PATCH"], + status_forcelist=[ + 408, + 502, + 503, + 504, + ], # 408:请求超时,502:网关错误,503:服务不可用,504:网关超时 + total=total, + respect_retry_after_header=True, + backoff_factor=backoff_factor, + ) + + # 创建适配器并绑定重试策略 + adapter = HTTPAdapter(max_retries=strategy_retries) + # 就HTTP请求生效 + session.mount("http://", adapter) + # 就HTTPS请求生效 + session.mount("https://", adapter) + + return session + + def _initialize_cache_database(self): + """初始化缓存数据库""" + + # 创建缓存数据库连接(使用SQLite) + self.cache_connection = sqlite3.connect( + database="SQLite.db", check_same_thread=False + ) + + self.cache_connection.execute( + """CREATE TABLE IF NOT EXISTS caches (guid TEXT PRIMARY KEY, response TEXT, timestamp REAL)""" + ) + # 创建时间戳索引 + self.cache_connection.execute( + """CREATE INDEX IF NOT EXISTS index_timestamp ON caches(timestamp)""" + ) + # 删除过期缓存 + self.cache_connection.execute( + "DELETE FROM caches WHERE timestamp < ?", + (time.time() - self.cache_ttl * 86400,), # 缓存生存时间单位转为秒 + ) + # 提交事物 + self.cache_connection.commit() + + # 在缓存数据库查询响应 + def _query_response(self, guid: str) -> Optional[Dict]: + + with threading.Lock(): + cursor = None + try: + # 创建游标 + cursor = self.cache_connection.cursor() + # 根据请求唯一标识查询响应 + cursor.execute( + "SELECT response FROM caches WHERE guid = ? AND timestamp >= ?", + (guid, time.time() - self.cache_ttl * 86400), + ) + if result := cursor.fetchone(): + return json.loads(result[0]) + return None + # 若发生异常则返回NONE + except: + self.cache_connection.rollback() + return None + finally: + if cursor: + cursor.close() + + # 将响应保存至缓存数据库 + def _save_response(self, guid: str, response: Dict): + + with threading.Lock(): + cursor = None + try: + # 创建游标 + cursor = self.cache_connection.cursor() + # 新增或覆盖响应 + cursor.execute( + "INSERT OR REPLACE INTO caches (guid, response, timestamp) VALUES (?, ?, ?)", + (guid, json.dumps(response, ensure_ascii=False), time.time()), + ) + # 提交事物 + self.cache_connection.commit() + # 若发生异常则返回NONE + except: + self.cache_connection.rollback() + finally: + if cursor: + cursor.close() + + # GET请求 + def get(self, **kwargs) -> Union[Dict, str]: + + return self._request(method="GET", arguments=Arguments(**kwargs)) + + # POST请求 + def post(self, **kwargs) -> Union[Dict, str]: + + return self._request(method="POST", arguments=Arguments(**kwargs)) + + # 文件下载 + def download( + self, stream=False, chunk_size=1024, **kwargs + ) -> Union[Dict, str, Generator[bytes, None, None]]: + + response = self._request( + method="GET", arguments=Arguments(**{"stream": stream, **kwargs}) + ) + + # 若禁用流式传输,则返回响应 + if not stream: + return response + # 若启用流式传输,则处理流式传输响应并返回 + return self._process_stream_response(response=response, chunk_size=chunk_size) + + def _request(self, method: Literal["GET", "POST"], arguments: Arguments) -> Any: + """发送请求""" + + # 请求参数模型 + arguments = arguments.model_dump(exclude_none=True, by_alias=True) + + # URL由HTTPURL对象转为字符串 + arguments["url"] = str(arguments["url"]) + + # 重构表单数据 + if arguments.get("data") is not None: + arguments["data"] = { + key: value + for key, value in arguments["data"].items() + if value is not None + } + + # 重构JSON格式数据 + if arguments.get("json_data") is not None: + arguments["json_data"] = { + key: value + for key, value in arguments["json_data"].items() + if value is not None + } + + # 重构文件数据 + if arguments.get("files") is not None: + files_valid = {} + # 遍历文件数据键值对 + for key, value in arguments["files"].items(): + if isinstance(value, (tuple, list)): + match len(value): + # 若文件数据包括文件名称和文件内容 + case 2: + files_valid[key] = (value[0], value[1], None, None) + # 若文件数据包含文件名称、文件内容和内容类型 + case 3: + files_valid[key] = (value[0], value[1], value[2], None) + # 若文件数据包含文件名称、文件内容、内容类型和请求头 + case 4: + files_valid[key] = (value[0], value[1], value[2], value[3]) + arguments.update({"files": files_valid}) + + # 全局唯一标识 + guid = arguments.pop("guid", None) + + # 若使用缓存且本次请求参数包含全局唯一标识,则优先返回缓存数据库中响应 + if self.cache_enabled and guid is not None: + # 在缓存数据库查询响应 + response = self._query_response(guid=guid) + # 若缓存响应非空则返回 + if response is not None: + return response + + try: + # 发送请求 + response = self.session.request( + method=method, timeout=self.timeout, **arguments + ) + + # 若返回错误状态码则抛出异常 + response.raise_for_status() + # 处理响应 + response = self._process_response(response=response) + + # 若请求全局唯一标识非NONE则响应保存至缓存数据库 + # noinspection PyUnboundLocalVariable + if guid is not None: + # noinspection PyUnboundLocalVariable + self._save_response(guid=guid, response=response) + + return response + + except Exception as exception: + # 尝试根据响应解析响应状态码和错误信息,否则进行构造 + try: + # JOSN反序列化 + # noinspection PyUnboundLocalVariable + response_decoded = response.json() + # 响应状态码 + status = response_decoded["status"] + # 错误信息 + message = response_decoded["message"] + except: + status = getattr(getattr(exception, "response", None), "status", None) + url = arguments["url"] + message = str(exception).split("\n")[0] + # 重新构建错误信息 + message = f"{method} {url} failed: {message}" + raise RequestException(status=status, message=message) + + # 处理响应 + @staticmethod + def _process_response(response: Response) -> Any: + + # 响应内容 + content = response.content + # 若响应内容为空则返回NONE + if not content: + return None + + # 标准化内容类型 + content_type = ( + response.headers.get("Content-Type", "").split(";")[0].strip().lower() + ) + + # 根据内容类型匹配解析返回内容方法 + # noinspection PyUnreachableCode + match content_type: + case "application/json" | "text/json": + # JSON反序列化 + return response.json() + case "application/xml" | "text/xml": + # 解析为XML(ELEMENT对象) + return ElementTree.fromstring(text=content) + case _: + # 若内容类型以IMAGE/开头则返回图片格式和图片数据 + if content_type.startswith("image/"): + # 图片格式 + image_format = content_type.split(sep="/", maxsplit=1)[1] + return f"{image_format}", content + else: + return content + + # 处理流式传输响应 + @staticmethod + def _process_stream_response( + response: Response, chunk_size: int + ) -> Generator[bytes, None, None]: # 生成器不接受发SEND发送至、结束时返回NONE + + # 检查数据分块 + if not isinstance(chunk_size, int) and isinstance(chunk_size, bool): + raise ValueError("chunk_size must type=int") + + if chunk_size <= 0: + raise ValueError("chunk_size must >0") + + try: + for chunk in response.iter_content(chunk_size=chunk_size): + if chunk: + yield chunk + finally: + response.close() + + +class Authenticator: + + def __init__( + self, + ): + """认证器(用于获取访问令牌)""" + + # 初始化 + self._initialize() + + def _initialize(self): + """初始化访问凭证""" + + # 创建访问凭证地址对象 + self.certifications_path = ( + Path(__file__).parent.resolve() / "certifications.json" + ) + # 若访问凭证地址对象不存在则创建 + if not self.certifications_path.exists(): + with open(self.certifications_path, "w", encoding="utf-8") as file: + json.dump( + {}, + file, + ensure_ascii=False, + ) + + # 初始化HTTP客户端 + self.http_client = HTTPClient() + + def _szkt_get_certification(self) -> tuple[str, float]: + """获取深圳快瞳访问凭证""" + + # 请求深圳快瞳访问凭证获取接口 + response = self.http_client.get( + url="https://ai.inspirvision.cn/s/api/getAccessToken?accessKey=APPID_6Gf78H59D3O2Q81u&accessSecret=947b8829d4d5d55890b304d322ac2d0d" + ) + + # 若响应非成功则抛出异常 + if not (response["status"] == 200 and response["code"] == 0): + raise RuntimeError("获取深圳快瞳访问凭证发生异常") + + # 返回令牌、失效时间戳 + # noinspection PyTypeChecker + return ( + response["data"]["access_token"], + time.time() + response["data"]["expires_in"], + ) + + def _hlyj_get_certification(self) -> tuple[str, float]: + """获取合力亿捷访问凭证""" + + # 企业访问标识 + access_key_id = "25938f1c190448829dbdb5d344231e42" + + # 签名秘钥 + secret_access_key = "44dc0299aff84d68ae27712f8784f173" + + # 时间戳(秒级) + timestamp = int(time.time()) + + # 签名,企业访问标识、签名秘钥和时间戳拼接后计算的十六进制的HMAC-SHA256 + signature = hmac.new( + secret_access_key.encode("utf-8"), + f"{access_key_id}{secret_access_key}{timestamp}".encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + # 请求合力亿捷访问凭证获取接口 + response = self.http_client.get( + url=f"https://kms.7x24cc.com/api/v1/corp/auth/token?access_key_id={access_key_id}×tamp={timestamp}&signature={signature}" + ) + + # 若响应非成功则抛出异常 + if not response["success"]: + raise RuntimeError("获取合力亿捷访问凭证发生异常") + + # 返回令牌、失效时间戳 + # noinspection PyTypeChecker + return ( + response["data"], + time.time() + 3600, # 访问令牌有效期为1小时 + ) + + def _feishu_get_certification(self) -> tuple[str, float]: + """获取飞书访问凭证""" + + # 请求飞书访问凭证获取接口 + response = self.http_client.post( + url="https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", + data={ + "app_id": "cli_a1587980be78500c", + "app_secret": "vZXGZomwfmyaHXoG8s810d1YYGLsIqCA", + }, + ) + + # 若响应非成功则抛出异常 + if not response["code"] == 0: + raise RuntimeError("获取飞书访问凭证发生异常") + + # 返回令牌、失效时间戳 + # noinspection PyTypeChecker + return ( + response["tenant_access_token"], + time.time() + response["expire"], + ) + + def get_token(self, servicer: str) -> str | None: + """获取访问令牌""" + """ + :param servicer: 服务商,数据类型为字符串 + """ + + with threading.Lock(): + # 初始化令牌和失效时间戳 + token, expired_timestamp = None, 0 + try: + with open(self.certifications_path, "r", encoding="utf-8") as file: + # 读取所有服务商访问凭证 + certifications = json.load(file) + # 获取服务商访问凭证 + certification = certifications.get(servicer, None) + # 若服务商访问凭证非NONE则解析令牌和失效时间戳 + if certification is not None: + # 解析服务商访问令牌 + token = certification["token"] + # 解析服务商访问令牌失效时间戳 + expired_timestamp = certification["expired_timestamp"] + + # 若JSON反序列化时发生异常则重置访问凭证 + except json.decoder.JSONDecodeError: + with open(self.certifications_path, "w", encoding="utf-8") as file: + json.dump( + {}, + file, + ensure_ascii=False, + ) + + except Exception: + raise RuntimeError("获取访问令牌发生异常") + + # 若当前时间戳大于失效时间戳,则请求服务商获取访问凭证接口 + if time.time() > expired_timestamp: + # noinspection PyUnreachableCode + match servicer: + case "szkt": + # 获取深圳快瞳访问凭证 + token, expired_timestamp = self._szkt_get_certification() + case "feishu": + token, expired_timestamp = self._feishu_get_certification() + case "hlyj": + token, expired_timestamp = self._hlyj_get_certification() + case _: + raise RuntimeError(f"服务商({servicer})未设置获取访问令牌方法") + + # 更新服务商访问凭证 + certifications[servicer] = { + "token": token, + "expired_timestamp": expired_timestamp, + } + + # 将所有服务商访问凭证保存至本地文件 + with open(self.certifications_path, "w", encoding="utf-8") as file: + json.dump( + certifications, + file, + ensure_ascii=False, + ) + + return token + + +""" +封装飞书客户端,实现获取验证码、操作多维表格等 +""" + + +class FeishuClinet: + + def __init__(self): + + self.authenticator = Authenticator() + + self.http_client = HTTPClient() + + def _headers(self): + """请求头""" + + # 装配飞书访问凭证 + return { + "Authorization": f"Bearer {self.authenticator.get_token(servicer='feishu')}", + } + + @staticmethod + def get_verification_code(): + + try: + + # 执行时间戳 + execute_timestamp = time.time() + + # 超时时间戳 + timeout_timestamp = execute_timestamp + 65 + + # 建立加密IMAP连接 + server = IMAP4_SSL("imap.feishu.cn", 993) + + # 登录 + server.login("mars@liubiren.cloud", "a2SfPUgbKDmrjPV2") + + while True: + + # 若当前时间戳大于超时时间戳则返回NONE + if time.time() <= timeout_timestamp: + + # 等待10秒 + time.sleep(10) + + # 选择文件夹(邮箱验证码) + server.select("&kK57sZqMi8F4AQ-") + + try: + + # 获取最后一封邮件索引,server.search()返回数据类型为元组,第一个元素为查询状态,第二个元素为查询结果(邮件索引字节串的列表);然后,从列表获取字节串并分割取最后一个,作为最后一封邮件索引 + index = server.search(None, "ALL")[1][0].split()[-1] + + # 获取最后一封邮件内容并解析,server.fetch()返回数据类型为元组,第一个元素为查询状态,第二个元素为查询结果(邮件内容字节串的列表);然后,从列表获取字节串并解析正文 + # noinspection PyUnresolvedReferences + contents = BytesParser(policy=default).parsebytes( + server.fetch(index, "(RFC822)")[1][0][1] + ) + + # 遍历邮件内容,若正文内容类型为纯文本或HTML则解析发送时间和验证码 + for content in contents.walk(): + + if ( + content.get_content_type() == "text/plain" + or content.get_content_type() == "text/html" + ): + + # 邮件发送时间戳 + # noinspection PyUnresolvedReferences + send_timestamp = parsedate_to_datetime( + content["Date"] + ).timestamp() + + # 若邮件发送时间戳大于执行时间戳则解析验证码并返回 + if ( + execute_timestamp + > send_timestamp + >= execute_timestamp - 35 + ): + + # 登出 + server.logout() + + # 解析验证码 + return re.search( + r"【普康健康】您的验证码是:(\d+)", + content.get_payload(decode=True).decode(), + ).group(1) + + # 若文件夹无邮件则继续 + except: + + pass + + # 若超时则登出 + else: + + server.logout() + + return None + + except Exception: + + raise RuntimeError("获取邮箱验证码发生其它异常") + + # 查询多维表格记录,单次最多查询500条记录 + @restrict(refill_rate=5, max_tokens=5) + def query_bitable_records( + self, + bitable: str, + table_id: str, + field_names: Optional[list[str]] = None, + filter_conditions: Optional[dict] = None, + ) -> pandas.DataFrame: + + # 先查询多维表格记录,在根据字段解析记录 + + # 装配多维表格查询记录地址 + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{bitable}/tables/{table_id}/records/search?page_size=20" + + response = self.http_client.post( + url=url, + headers=self._headers(), + json={"field_names": field_names, "filter": filter_conditions}, + ) + + # 响应业务码为0则定义为响应成功 + assert response.get("code") == 0, "查询多维表格记录发生异常" + + # 多维表格记录 + records = response.get("data").get("items") + + # 检查响应中是否包含还有下一页标识,若有则继续请求下一页 + while response.get("data").get("has_more"): + + url_next = url + "&page_token={}".format( + response.get("data").get("page_token") + ) + + response = self.http_client.post( + url=url_next, + headers=self._headers(), + json={"field_names": field_names, "filter": filter_conditions}, + ) + + assert response.get("code") == 0, "查询多维表格记录发生异常" + + # 合并记录 + records.append(response.get("data").get("items")) + + # 装配多维表格列出字段地址 + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{bitable}/tables/{table_id}/fields?page_size=20" + + response = self.http_client.get( + url=url, + headers=self._headers(), + ) + + assert response.get("code") == 0, "列出多维表格字段发生异常" + + # 多维表格字段 + fields = response.get("data").get("items") + + while response.get("data").get("has_more"): + + url_next = url + "&page_token={}".format( + response.get("data").get("page_token") + ) + + response = self.http_client.get( + url=url_next, + headers=self._headers(), + ) + + assert response.get("code") == 0, "列出多维表格字段发生异常" + + fields.append(response.get("data").get("items")) + + # 字段映射 + field_mappings = {} + + for field in fields: + + # 字段名 + field_name = field["field_name"] + + # 根据字段类型匹配 + match field["type"]: + + case 1005: + + field_type = "主键" + + case 1: + + field_type = "文本" + + case 3: + + field_type = "单选" + + case 2: + + # 数字、公式字段的显示格式 + match field["property"]["formatter"]: + + case "0": + + field_type = "整数" + + case _: + + raise ValueError("未设置数字、公式字段的显示格式") + + case _: + + raise ValueError("未设置字段类型") + + # noinspection PyUnboundLocalVariable + field_mappings.update({field_name: field_type}) + + # 记录数据体 + records_data = [] + + # 解析记录 + for record in records: + + # 单条记录数据体 + record_data = {} + + for field_name, content in record["fields"].items(): + + match field_mappings[field_name]: + + case "主键" | "单选" | "整数": + + record_data.update({field_name: content}) + + case "文本": + + # 若存在多行文本则拼接 + fragments_content = "" + + for fragment_content in content: + + fragments_content += fragment_content["text"] + + record_data.update({field_name: fragments_content}) + + case _: + + raise ValueError("未设置字段解析方法") + + records_data.append(record_data) + + return pandas.DataFrame(records_data) diff --git a/任务调度服务器/dagster.yaml b/任务调度服务器/dagster.yaml new file mode 100644 index 0000000..ae945e1 --- /dev/null +++ b/任务调度服务器/dagster.yaml @@ -0,0 +1,16 @@ +storage: + sqlite: + base_dir: + env: SQLITE_STORAGE_BASE_DIR + +compute_logs: + module: dagster.core.storage.local_compute_log_manager + class: LocalComputeLogManager + config: + env: LOCAL_COMPUTE_LOG_MANAGER_DIRECTORY + +local_artifact_storage: + module: dagster.core.storage.root + class: LocalArtifactStorage + config: + env: DAGSTER_LOCAL_ARTIFACT_STORAGE_DIR \ No newline at end of file diff --git a/普康健康审核机器人/cognition.py b/普康健康审核机器人/cognition.py new file mode 100644 index 0000000..bfa22a6 --- /dev/null +++ b/普康健康审核机器人/cognition.py @@ -0,0 +1,2039 @@ +# -*- coding: utf-8 -*- + +''' + +脚本说明:普康健康规则和策略模型 + +''' + + +#导入模块 + +import json + +import re + +from datetime import datetime, date + +from dateutil.relativedelta import relativedelta + +import pandas + +import numpy + +#规则模版 +decisions = { + + '中银保规则模版一': { + + '出险人姓名非空': None, + + '出险人证件类型为居民身份证且通过校验码校验': None, + + '出险人证件有效': None, + + '主被保险人姓名非空': None, + + '主被保险人证件类型为居民身份证且通过校验码校验': None, + + '主被保险人证件有效': None, + + '领款人姓名非空': None, + + '领款人证件类型为居民身份证且通过校验码校验': None, + + '领款人证件有效': None, + + '出险人和主被保险人关系与所选一致': None, + + '出险人和领款人关系与所选一致': None, + + '开户行非空': None, + + '银行账户通过LUHN校验': None, + + '联系电话非空': None, + + '联系电话通过校验': None, + + #'证件资料数大于等于2': None, + + #'证件资料包含证件正面': None, + + #'证件资料包含证件背面': None, + + #'票据资料数大于等于1': None, + + #'未分类资料数等于0': None, + + '票据就诊类型均非住院治疗': None, + + '票据开具日期起期和开具日期止期均为同一日': None, + + '票据诊断疾病均非空': None, + + '票据备注均非疾病': None, + + '票据合理金额、部分自费和全部自费之和大于0': None, + + '票据关联责任均为同一类型': None, + + '理算第一行保额大于0': None + + }, + + '中银保规则模版二': { + + '出险人姓名非空': None, + + '出险人证件类型为居民身份证且通过校验码校验': None, + + '出险人证件有效': None, + + '主被保险人姓名非空': None, + + '主被保险人证件类型为居民身份证且通过校验码校验': None, + + '主被保险人证件有效': None, + + '领款人姓名非空': None, + + '领款人证件类型为居民身份证且通过校验码校验': None, + + '领款人证件有效': None, + + '出险人和主被保险人关系与所选一致': None, + + '出险人和领款人关系与所选一致': None, + + '开户行非空': None, + + '银行账户通过LUHN校验': None, + + '联系电话非空': None, + + '联系电话通过校验': None, + + #'证件资料数大于等于2': None, + + #'证件资料包含证件正面': None, + + #'证件资料包含证件背面': None, + + #'票据资料数大于等于1': None, + + #'未分类资料数等于0': None, + + '票据就诊类型均为药店购药': None, + + '票据开具日期起期和开具日期止期均为同一日': None, + + '票据诊断疾病均非空': None, + + '票据备注均非疾病': None, + + '票据合理金额、部分自费和全部自费之和大于0': None, + + '票据关联责任均为同一类型': None, + + '理算第一行保额大于0': None + + }, + + '永诚规则模版一': { + + '出险人姓名非空': None, + + '出险人证件类型为居民身份证且通过校验码校验': None, + + '非空出险人证件有效': None, + + '线下报案出险地址非空': None, + + '主被保险人姓名非空': None, + + '主被保险人证件类型为居民身份证且通过校验码校验': None, + + '非空主被保险人证件有效': None, + + '领款人姓名非空': None, + + '领款人证件类型为居民身份证且通过校验码校验': None, + + '赔付金额超过一万元领款人证件有效': None, + + '开户行非空': None, + + '银行账户通过LUHN校验(忽略18位)': None, + + '联系电话非空': None, + + '联系电话通过校验': None, + + '赔付金额超过一万元联系地址非空': None, + + '未分类资料数等于0': None, + + '无重复票据提示': None, + + '票据交款人均为出险人': None, + + '票据诊断疾病均非空': None, + + '票据合理金额、部分自费和全部自费之和大于0': None, + + '理算第一行保额大于0': None + + }, + + '瑞泰规则模版一': { + + '出险类型非空': None, + + '出险人姓名非空': None, + + '出险人证件类型为居民身份证且通过校验码校验': None, + + '主被保险人姓名非空': None, + + '主被保险人证件类型为居民身份证且通过校验码校验': None, + + '领款人姓名非空': None, + + '领款人证件类型为居民身份证且通过校验码校验': None, + + '开户行非空': None, + + '开户行分行非空': None, + + '开户行分行所在省市非空': None, + + '银行账户通过LUHN校验': None, + + '联系电话非空': None, + + '联系电话通过校验': None, + + '联系地址非空': None, + + '票据交款人均为出险人': None, + + '票据诊断疾病均非空': None, + + '理算第一行保额大于0': None, + + '票据合理金额、部分自费和全部自费之和大于0': None, + + '赔付金额小于3000': None + + } + +} + +class Cognition: + + def __init__(self, extractions): + + self.extractions = extractions + + #数据转化 + def transform(self): + + data = { + + '票据影像件序号': [], + + '申请书影像件序号': [], + + '证件影像件序号': [], + + '银行卡折影像件序号': [], + + '未分类影像件序号': [] + + } + + #遍历解析抽取内容 + for extraction in self.extractions: + + #遍历键值对 + for key, value in list(extraction.items()): + + match key: + + case '票据影像件序号': + + try: + + content = value.split(':')[1] + + #仅将关联票据序号保存 + if content != '' and content.isdigit(): + + data['票据影像件序号'].append(content) + + except: + + data['票据影像件序号'].append(value) + + case '申请书影像件序号': + + data['申请书影像件序号'].append(value) + + case '证件影像件类型': + + match value: + + case '身份证正面': + + data['证件影像件序号'].append('居民身份证正面') + + case '身份证反面': + + data['证件影像件序号'].append('居民身份证背面') + + case default: + + data['证件影像件序号'].append(value) + + case '银行卡折影像件序号': + + data['银行卡折影像件序号'].append(value) + + case '未分类影像件序号': + + data['未分类影像件序号'].append(value) + + case '报案保单号': + + #原始报案保单号为“客户报案保单:20241201000”,其中20241201000为报案保单号 + try: + + content = value.split(':')[1] + + except: + + content = None + + data.update({key: content}) + + case '报案方式': + + match value: + + case '赔案来源:线下': + + content = '线下报案' + + case '赔案来源:线上': + + content = '线上报案' + + case default: + + content = None + + data.update({key: content}) + + case '理赔类型': + + match value: + + case '理赔类型:门诊': + + content = '门急诊就诊' + + case '理赔类型:其他': + + content = '药店购药' + + case default: + + content = None + + data.update({key: content}) + + case condition if '证件号' in key: + + data.update({key: value}) + + case condition if '证件类型' in key: + + match value: + + case '身份证': + + content = '居民身份证' + + case default: + + content = None + + data.update({key: content}) + + case condition if '证件有效期' in key: + + #在永诚审核页面证件有效期日期格式为%Y-%m-%d,在瑞泰审核页面证件有效期日期格式为%Y%m%d。先尝试按照日期格式%Y-%m-%d,再尝试按照日期格式%Y%m%d解析 + try: + + #若证件有效期为9999-12-31则转为2099-12-31 + if value == '9999-12-31': + + value = '2099-12-31' + + content = datetime.strptime(value, '%Y-%m-%d').date() + + except: + + try: + + #若证件有效期为99991231则转为20991231 + if value == '99991231': + + value = '20991231' + + content = datetime.strptime(value, '%Y%m%d').date() + + except: + + content = None + + data.update({key: content}) + + case '出险人与主被保险人关系' | '出险人与领款人关系': + + match value: + + case '本人' | '': + + content = '本人' + + case default: + + content = None + + data.update({key: content}) + + case '银行账户': + + try: + + content = value.replace(' ', '') + + except: + + content = None + + data.update({key: content}) + + case '票据提示': + + if '发票查重:' in value: + + content = '票据重复提示' + + else: + + content = '无票据重复提示' + + data.update({key: content}) + + case '保单信息': + + content = [] + + #遍历保单信息 + for slip in value: + + slip_content = {} + + for nested_key, nested_value in list(slip.items()): + + match nested_key: + + case '保障期': + + try: + + slip_content.update({'保障期起期': datetime.strptime(nested_value.split(' - ')[0].split(' ')[0], '%Y-%m-%d').date()}) + + slip_content.update({'保障期止期': datetime.strptime(nested_value.split(' - ')[1].split(' ')[0], '%Y-%m-%d').date()}) + + except: + + slip_content.update({'保障期起期': None}) + + slip_content.update({'保障期止期': None}) + + case default: + + slip_content.update({nested_key: nested_value}) + + content.append(slip_content) + + data.update({key: content}) + + case '票据信息': + + content = [] + + #遍历票据 + for invoice in value: + + invoice_content = {} + + for nested_key, nested_value in list(invoice.items()): + + match nested_key: + + case '就诊类型': + + match nested_value: + + case '药房': + + nested_content = '药店购药' + + case '门/急诊': + + nested_content = '门急诊就诊' + + case '住院': + + nested_content = '住院治疗' + + case default: + + nested_content = None + + invoice_content.update({nested_key: nested_content}) + + case '开具日期': + + #在永诚审核页面开具日期日期格式为%Y-%m-%d,在瑞泰审核页面开具日期格式为%Y%m%d。先尝试按照日期格式%Y-%m-%d,再尝试按照日期格式%Y%m%d解析 + try: + + invoice_content.update({'开具日期起期': datetime.strptime(nested_value.split(' - ')[0], '%Y-%m-%d').date()}) + + invoice_content.update({'开具日期止期': datetime.strptime(nested_value.split(' - ')[1], '%Y-%m-%d').date()}) + + except: + + try: + + invoice_content.update({'开具日期起期': datetime.strptime(nested_value.split(' - ')[0], '%Y%m%d').date()}) + + invoice_content.update({'开具日期止期': datetime.strptime(nested_value.split(' - ')[1], '%Y%m%d').date()}) + + except: + + invoice_content.update({'开具日期起期': None}) + + invoice_content.update({'开具日期止期': None}) + + case '票据验真': + + match nested_value: + + case '真票': + + nested_content = '已验真' + + case '换开': + + nested_content = '已验换开' + + case '红冲': + + nested_content = '已验红冲' + + case '假票': + + nested_content = '已验假票' + + case '无法验真/查无此票': + + nested_content = '无法查验' + + case '未验真': + + nested_content = '未查验' + + case default: + + nested_content = None + + invoice_content.update({nested_key: nested_content}) + + case condition if '金额' in nested_key or '自费' in nested_key: + + try: + + invoice_content.update({nested_key: float(nested_value)}) + + except: + + invoice_content.update({nested_key: None}) + + case default: + + invoice_content.update({nested_key: nested_value}) + + content.append(invoice_content) + + data.update({key: content}) + + case '理算第一行保额': + + try: + + content = float(value) + + except: + + content = None + + data.update({key: content}) + + case '审核结论': + + match value: + + case '赔付': + + content = 1 + + case condition if value in ['拒赔', '拒付']: + + content = 0 + + case default: + + content = None + + data.update({key: content}) + + case condition if '金额' in key or '保额' in key: + + try: + + data.update({key: float(value)}) + + except: + + data.update({key: None}) + + case default: + + data.update({key: value}) + + return data + + #理算前认知模型 + def before_adjustment(self, insurance): + + data = self.transform() + + #自动审核默认为是 + auto_audit = True + + #所选保单索引 + slip_index = None + + #所选保单所属保险分公司 + slip_insurance_branch = None + + #所选保单保障期起期 + slip_assurance_start = None + + #所选保单保障期止期 + slip_assurance_end = None + + try: + + #若报案方式为线上报案则将报案保单作为所选保单 + if data.get('报案方式') == '线上报案' and isinstance(data.get('报案保单号'), str): + + #遍历保单信息查询保单号与报案保单号相同的保单 + for index, slip in enumerate(data.get('保单信息')): + + if slip.get('保单号') == data.get('报案保单号'): + + #获取所选保单索引 + slip_index = index + 1 + + #获取所选保单保险分公司 + slip_insurance_branch = slip.get('保险分公司') + + #获取所选保单保障期起期 + slip_assurance_start = slip.get('保障期起期') + + #获取所选保单保障期止期 + slip_assurance_end = slip.get('保障期止期') + + #若报案方式非线上报案则先根据票据定义出险日期,再根据出险日期匹配保单 + else: + + try: + + #定义出险日期 + occurrence_date = datetime.strptime('0001-01-01', '%Y-%m-%d').date() + + #遍历票据以最大开具日期起期作为出险日期 + for invoice in data.get('票据信息'): + + if invoice.get('开具日期起期') > occurrence_date: + + occurrence_date = invoice.get('开具日期起期') + + #遍历保单查询保单保障期起期小于等于出险日期且保单保障期止期大于等于出险日期且保险分公司名称包含保险总公司名称的第一张保单作为所选保单 + for index, slip in enumerate(data.get('保单信息')): + + if slip.get('保障期起期') <= occurrence_date and slip.get('保障期止期') >= occurrence_date and insurance in slip.get('保险分公司'): + + slip_index = index + 1 + + slip_insurance_branch = slip.get('保险分公司') + + slip_assurance_start = slip.get('保障期起期') + + slip_assurance_end = slip.get('保障期止期') + + break + + #断定所选保单索引非空 + assert slip_index is not None + + #若根据票据无法匹配保单则以保险分公司名称包含保险总公司名称的最后一张保单作为所选保单 + except: + + for index, slip in range(len(data.get('保单信息')), 0, -1): + + if insurance in slip.get('保险分公司'): + + slip_index = index + + slip_insurance_branch = slip.get('保险分公司') + + slip_assurance_start = slip.get('保障期起期') + + slip_assurance_end = slip.get('保障期止期') + + break + + #若上述无法选择保单则人工审核 + except: + + auto_audit = False + + #拒付票据默认为否 + invoices_refuse = False + + #需要拒付的票据索引 + invoices_indices = { + + '不在保单保障期的票据索引': [], + + '交款人非出险人的票据索引': [], + + '已验换开的票据索引': [], + + '已验红冲的票据索引': [], + + '已验假票的票据索引': [], + + '无法验真的票据索引': [], + + '未验真的票据索引': [] + + } + + #需要拒付的票据号(用于拼接赔付时结论原因) + invoices_numbers = { + + '不在保单保障期的票据号': [], + + '交款人非出险人的票据号': [], + + '已验换开的票据号': [], + + '已验红冲的票据号': [], + + '已验假票的票据号': [], + + '无法验真的票据号': [], + + '未验真的票据号': [] + + } + + #遍历票据 + for index, invoice in enumerate(data.get('票据信息')): + + index += 1 + + #若票据开具日期起期小于保单保障期或票据开具日期止期大于保单保障期止期则将该票据索引添加至不在保单保障期的票据索引 + if invoice.get('开具日期起期') < slip_assurance_start or invoice.get('开具日期止期') > slip_assurance_end: + + invoices_indices.get('不在保单保障期的票据索引').append(index) + + invoices_numbers.get('不在保单保障期的票据号').append(invoice.get('票据号')) + + #若交款人非出险人则将该票据索引添加至交款人非出险人的票据索引 + if invoice.get('交款人') != data.get('出险人姓名'): + + invoices_indices.get('交款人非出险人的票据索引').append(index) + + invoices_numbers.get('交款人非出险人的票据号').append(invoice.get('票据号')) + + #根据票据查验结果匹配(若票据验真结果为空默认按照票据赔付处理) + match invoice.get('票据验真'): + + case '已验换开': + + invoices_indices.get('已验换开的票据索引').append(index) + + invoices_numbers.get('已验换开的票据号').append(invoice.get('票据号')) + + case '已验红冲': + + invoices_indices.get('已验红冲的票据索引').append(index) + + invoices_numbers.get('已验红冲的票据号').append(invoice.get('票据号')) + + case '已验假票': + + invoices_indices.get('已验假票的票据索引').append(index) + + invoices_numbers.get('已验假票的票据号').append(invoice.get('票据号')) + + case '无法查验': + + invoices_indices.get('无法验真的票据索引').append(index) + + invoices_numbers.get('无法验真的票据号').append(invoice.get('票据号')) + + case '未查验': + + invoices_indices.get('未验真的票据索引').append(index) + + invoices_numbers.get('未验真的票据号').append(invoice.get('票据号')) + + #根据保险分公司判断是否需修改票据不合理金额 + match slip_insurance_branch: + + #针对永诚(目前是辽宁、黑龙江和浙江分公司) + #拒付不在保单保单保障期、交款人非出险人、查验结果为已验换开/红冲/假票的票据 + #人工处理查验结果为无法查验/未验真的票据 + case condition if '永诚财产保险股份有限公司' in slip_insurance_branch: + + for key, value in list(invoices_indices.items()): + + if key in ['不在保单保障期的票据索引', '交款人非出险人的票据索引', '已验换开的票据索引', '已验红冲的票据索引', '已验假票的票据索引'] and value != []: + + invoices_refuse = True + + if key in ['无法验真的票据索引', '未验真的票据索引'] and value != []: + + invoices_indices[key] = [] + + auto_audit = False + + #针对瑞泰(目前是北京分公司) + #拒付不在保单保单保障期、交款人非出险人、查验结果为已验换开/红冲/假票的票据 + #人工处理查验结果为无法查验 + #赔付查验结果为未验真的票据(按照余富强老师规则配置) + case condition if '瑞泰人寿保险有限公司' in slip_insurance_branch: + + for key, value in list(invoices_indices.items()): + + if key in ['不在保单保障期的票据索引', '交款人非出险人的票据索引', '已验换开的票据索引', '已验红冲的票据索引', '已验假票的票据索引'] and value != []: + + invoices_refuse = True + + if key in ['无法验真的票据索引'] and value != []: + + invoices_indices[key] = [] + + auto_audit = False + + if key in ['未验真的票据索引'] and value != []: + + invoices_indices[key] = [] + + #默认 + #拒付不在保单保单保障期、交款人非出险人的票据 + case default: + + for key, value in list(invoices_indices.items()): + + if key in ['不在保单保障期的票据索引', '交款人非出险人的票据索引'] and value != []: + + invoices_refuse = True + + if key in ['已验换开的票据索引', '已验红冲的票据索引', '已验假票的票据索引', '无法验真的票据索引', '未验真的票据索引'] and value != []: + + invoices_indices[key] = [] + + #若需要拒付的票据索引为空列表则删除 + for key, value in list(invoices_indices.items()): + + if value == []: + + invoices_indices.pop(key) + + #根据保险分公司匹配赔付时结论原因拼接方案 + match slip_insurance_branch: + + #针对永诚和瑞泰 + #赔付时结论原因仅保留不在保单保单保障期、交款人非出险人、查验结果为已验换开/红冲/假票的票据号 + case condition if '永诚财产保险股份有限公司' in slip_insurance_branch or '瑞泰人寿保险有限公司' in slip_insurance_branch: + + for key, value in list(invoices_numbers.items()): + + if key not in ['不在保单保障期的票据号', '交款人非出险人的票据号', '已验换开的票据号', '已验红冲的票据号', '已验假票的票据号'] and value != []: + + invoices_numbers[key] = [] + + #默认 + #赔付时结论原因仅保留不在保单保单保障期、交款人非出险人的票据号 + case default: + + for key, value in list(invoices_numbers.items()): + + if key not in ['不在保单保障期的票据号', '交款人非出险人的票据号'] and value != []: + + invoices_numbers[key] = [] + + #赔付时结论原因 + payment_remark = '' + + #遍历所该票据索引,若票据索引列表非空列表则拼接至赔付时结论原因(不包括未验真,保司要求TPA验真,如果显示未验真则我司过错) + for key, value in list(invoices_numbers.items()): + + if invoices_numbers.get(key) != []: + + match key: + + case '不在保单保障期的票据号': + + payment_remark += ','.join(invoices_numbers.get(key)) + '不在保单保障期歉难给付;' + + case '交款人非出险人的票据号': + + payment_remark += ','.join(invoices_numbers.get(key)) + '非本人发票歉难给付;' + + case '已验换开的票据号': + + payment_remark += ','.join(invoices_numbers.get(key)) + '换开歉难给付;' + + case '已验红冲的票据号': + + payment_remark += ','.join(invoices_numbers.get(key)) + '红冲歉难给付;' + + case '已验假票的票据号': + + payment_remark += ','.join(invoices_numbers.get(key)) + '假票歉难给付;' + + case '无法验真的票据号': + + payment_remark += ','.join(invoices_numbers.get(key)) + '无法查验歉难给付;' + + case '未验真的票据号': + + payment_remark += ','.join(invoices_numbers.get(key)) + '未查验歉难给付;' + + #若复核人为空则人工审核 + if data.get('复核人') != '无': + + auto_audit = False + + #若票据提示为“票据重复提示”则人工审核 + if data.get('票据提示') == '票据重复提示': + + auto_audit = False + + #根据保险分公司判断是否可自动审核 + match slip_insurance_branch: + + #针对瑞泰(目前是北京分公司) + #若开户行包含平安则人工审核 + case condition if '瑞泰人寿保险有限公司' in slip_insurance_branch: + + if '平安' in data.get('开户行'): + + auto_audit = False + + #在线报案默认为否 + case_report = False + + #根据保险分公司判断需要在线报案 + match slip_insurance_branch: + + case condition if '永诚财产保险股份有限公司' in slip_insurance_branch and data.get('报案方式') == '线下报案': + + case_report = True + + return {'自动审核': auto_audit, '所选保单索引': slip_index, '所选保单所属保险分公司': slip_insurance_branch, '票据拒付': invoices_refuse, '拒付票据索引': invoices_indices, '赔付结论原因': payment_remark, '在线报案': case_report, '转换数据': data} + + #校验:数据类型为字符且非空字符 + def is_string(self, content): + + try: + + assert isinstance(content, str) and content != '' + + result = 1 + + except: + + result = 0 + + finally: + + return result + + #校验:若证件类型为居民身份证则满足校验码校验 + def is_identification_card(self, content): + + try: + + assert isinstance(content, str) and content != '' + + #使用居民身份证校验码校验时前十七位系数 + coefficients = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + + #获取校验码索引 + index = sum([int(x) * y for x, y in zip(content[: -1], coefficients)]) % 11 + + #断定校验码和最后一位相同 + assert content[-1].upper() == '10X98765432'[index] + + result = 1 + + except: + + result = 0 + + finally: + + return result + + #校验:根据证件有效期止期校验证件有效 + def is_valid(self, content): + + try: + + assert isinstance(content, date) + + data = self.transform() + + #统计所有票据最小开具日期作为出险日期 + occurrence_date = min([invoice.get('开具日期起期') for invoice in data.get('票据信息')]) + + #断定证件有效期止期大于等于出险日期 + assert content >= occurrence_date + + result = 1 + + except: + + result = 0 + + finally: + + return result + + #校验:根据居民身份证中有效期校验已成年 + def is_adult(self, content): + + try: + + assert isinstance(content, str) and content != '' + + data = self.transform() + + #统计所有票据最小开具日期作为出险日期 + occurrence_date = min([invoice.get('开具日期起期') for invoice in data.get('票据信息')]) + + #断定居民身份证中出生日期和出险日期的相差年份大于等于十八 + assert relativedelta(occurrence_date, datetime.strptime(content[6: 14], '%Y%m%d')).years >= 18 + + result = 1 + + except: + + result = 0 + + finally: + + return result + + #校验:根据与出险人关系校验证件号相同 + def verify_relationship(self, content): + + match content: + + case '主被保险人': + + try: + + data = self.transform() + + relationship_type = data.get('出险人与主被保险人关系') + + match relationship_type: + + case '本人': + + assert data.get('出险人证件号') == data.get('主被保险人证件号') + + case default: + + assert data.get('出险人证件号') != data.get('主被保险人证件号') + + result = 1 + + except: + + result = 0 + + case '领款人': + + try: + + data = self.transform() + + relationship_type = data.get('出险人与领款人关系') + + match relationship_type: + + case '本人' | None: + + assert data.get('出险人证件号') == data.get('领款人证件号') + + case default: + + assert data.get('出险人证件号') != data.get('领款人证件号') + + result = 1 + + except: + + result = 0 + + case default: + + result = 0 + + return result + + #校验:银行账户满足LUHN校验 + def is_bank_card(self, content): + + #银行卡 + #若银行账户位数大于等于16且小于等于19则使用LUHN校验 + if len(content) >= 16 and len(content) <= 19: + + #将银行账户转为数字列表 + digits = [int(digit) for digit in content if digit.isdigit()] + + digits_sum = 0 + + for index, digit in enumerate(reversed(digits)): + + #若数字索引加上一为偶数则数字乘以二 + if (index + 1) % 2 == 0: + + digit *= 2 + + #若数字大于九则减去九 + if digit > 9: + + digit -= 9 + + digits_sum += digit + + #断定数字和能够被十整除 + if digits_sum % 10 == 0: + + result = 1 + + else: + + result = 0 + + #存折 + else: + + result = 1 + + return result + + #校验:联系电话数据类型为字符,11位,每一位均为数字、第一位为1、第二位为3、4、5、6、7、8、9、后面每一位均为0、1、2、3、4、5、6、7、8、9 + def is_mobile_phone(self, content): + + try: + + #断定数据类型为字符且为11位 + assert isinstance(content, str) and len(content) == 11 + + #遍历每一位 + for index in range(len(content)): + + #断定每一位均为数字 + assert content[index].isdigit() + + match index: + + case 0: + + assert content[index] == '1' + + case 1: + + assert content[index] in '3456789' + + case default: + + assert content[index] in '0123456789' + + result = 1 + + except: + + result = 0 + + finally: + + return result + + #生成规则结果并决策 + def after_adjustment(self, insurance): + + data = self.transform() + + #解析所选保单所属分公司 + slip_insurance_branch = self.before_adjustment(insurance = insurance).get('所选保单所属保险分公司') + + #默认足额赔付为True + payment_complement = True + + #根据保险分公司匹配足额赔付处理方式 + match slip_insurance_branch: + + case condition if '瑞泰人寿保险有限公司' in slip_insurance_branch: + + #统计所有票据的合理金额 + reasonable_amounts = sum([invoice.get('合理金额') for invoice in data.get('票据信息')]) + + #统计所有票据的部分自费 + part_self_amounts = sum([invoice.get('部分自费') for invoice in data.get('票据信息')]) + + #统计所有票据的全部自费 + all_self_amounts = sum([invoice.get('全部自费') for invoice in data.get('票据信息')]) + + #若第一行保额小于合理金额、部分自费和全部自费之和则足额赔付为False + if data.get('理算第一行保额') < reasonable_amounts + part_self_amounts + all_self_amounts: + + payment_complement = False + + decision = {'赔案号': data.get('赔案号'), '审核结论': data.get('审核结论'), '自动化:审核说明': '', '足额赔付': payment_complement} + + #根据保险分公司匹配规则和决策模型 + match slip_insurance_branch: + + case condition if slip_insurance_branch in ['中银保险有限公司广东分公司', '中银保险有限公司广州中心支公司', '中银保险有限公司东莞中心支公司', '中银保险有限公司惠州中心支公司', '中银保险有限公司肇庆中心支公司', '中银保险有限公司广西分公司', '中银保险有限公司天津分公司', '中银保险有限公司云南分公司', '中银保险有限公司陕西分公司', '中银保险有限公司苏州分公司', '中银保险有限公司扬州中心支公司', '中银保险有限公司无锡中心支公司', '中银保险有限公司泰州中心支公司']: + + decision.update({'规则结果': decisions.get('中银保规则模版一')}) + + case condition if slip_insurance_branch in ['中银保险有限公司江西分公司', '中银保险有限公司吉林分公司', '中银保险有限公司辽宁分公司', '中银保险有限公司营口中心支公司']: + + decision.update({'规则结果': decisions.get('中银保规则模版二')}) + + case condition if slip_insurance_branch in ['永诚财产保险股份有限公司黑龙江分公司']: + + decision.update({'规则结果': decisions.get('永诚规则模版一')}) + + case condition if slip_insurance_branch in ['瑞泰人寿保险有限公司北京分公司']: + + decision.update({'规则结果': decisions.get('瑞泰规则模版一')}) + + case default: + + raise Exception('保险分公司 {} 未配置规则和决策模型'.format(slip_insurance_branch)) + + #遍历规则键值对 + for key, value in list(decision.get('规则结果').items()): + + match key: + + case '出险类型非空': + + result = self.is_string(content = data.get('出险类型')) + + decision['规则结果'].update({key: result}) + + #若规则结果为0则补充审核说明 + if result == 0: + + decision.update({'自动化:审核说明': '{}出险类型为空;'.format(decision.get('自动化:审核说明'))}) + + case '出险人姓名非空': + + result = self.is_string(content = data.get('出险人姓名')) + + decision['规则结果'].update({key: result}) + + #若规则结果为0则补充审核说明 + if result == 0: + + decision.update({'自动化:审核说明': '{}出险人姓名为空;'.format(decision.get('自动化:审核说明'))}) + + case '出险人证件类型为居民身份证且通过校验码校验': + + result = self.is_identification_card(content = data.get('出险人证件号')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}出险人居民身份证有误;'.format(decision.get('自动化:审核说明'))}) + + case '出险人证件有效': + + #若出险人证件有效期止期为空则判断领款人证件号和是否一致,若一致则取领款人证件有限期止期 + if data.get('出险人证件有效期止期') is None: + + if data.get('出险人证件号') == data.get('领款人证件号'): + + result = self.is_valid(content = data.get('领款人证件有效期止期')) + + else: + + result = 0 + + else: + + result = self.is_valid(content = data.get('出险人证件有效期止期')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}出险人证件过期;'.format(decision.get('自动化:审核说明'))}) + + case '非空出险人证件有效': + + if data.get('出险人证件有效期止期') is not None: + + result = self.is_valid(content = data.get('出险人证件有效期止期')) + + else: + + result = 1 + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}出险人证件过期;'.format(decision.get('自动化:审核说明'))}) + + case '出险人已成年': + + result = self.is_adult(content = data.get('出险人证件号')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}出险人未成年;'.format(decision.get('自动化:审核说明'))}) + + case '线下报案出险地址非空': + + if data.get('报案方式') == '线下报案': + + result = self.is_string(content = data.get('出险地址')) + + else: + + result = 1 + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}线下报案出险地址为空;'.format(decision.get('自动化:审核说明'))}) + + case '主被保险人姓名非空': + + result = self.is_string(content = data.get('主被保险人姓名')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}主被保险人姓名为空;'.format(decision.get('自动化:审核说明'))}) + + case '主被保险人证件类型为居民身份证且通过校验码校验': + + result = self.is_identification_card(content = data.get('主被保险人证件号')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}主被保险人居民身份证有误;'.format(decision.get('自动化:审核说明'))}) + + case '主被保险人证件有效': + + result = self.is_valid(content = data.get('主被保险人证件有效期止期')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}主被保险人证件过期;'.format(decision.get('自动化:审核说明'))}) + + case '非空主被保险人证件有效': + + if data.get('主被保险人证件有效期止期') is not None: + + result = self.is_valid(content = data.get('主被保险人证件有效期止期')) + + else: + + result = 1 + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}出险人证件过期;'.format(decision.get('自动化:审核说明'))}) + + case '领款人姓名非空': + + result = self.is_string(content = data.get('领款人姓名')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}领款人姓名为空;'.format(decision.get('自动化:审核说明'))}) + + case '领款人证件类型为居民身份证且通过校验码校验': + + result = self.is_identification_card(content = data.get('领款人证件号')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}领款人居民身份证有误;'.format(decision.get('自动化:审核说明'))}) + + case '领款人证件有效': + + result = self.is_valid(content = data.get('领款人证件有效期止期')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}领款人证件过期;'.format(decision.get('自动化:审核说明'))}) + + case '赔付金额超过一万元领款人证件有效': + + if data.get('理算金额') >= 10000: + + result = self.is_valid(content = data.get('领款人证件有效期止期')) + + else: + + if self.is_string(content = data.get('领款人证件有效期止期')) == 1: + + result = self.is_valid(content = data.get('领款人证件有效期止期')) + + else: + + result = 1 + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}领款人证件过期;'.format(decision.get('自动化:审核说明'))}) + + case '出险人和主被保险人关系与所选一致': + + result = self.verify_relationship(content = '主被保险人') + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}出险人和主被保险人非同一人;'.format(decision.get('自动化:审核说明'))}) + + case '出险人和领款人关系与所选一致': + + result = self.verify_relationship(content = '领款人') + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}出险人和领款人非同一人;'.format(decision.get('自动化:审核说明'))}) + + case '开户行非空': + + result = self.is_string(content = data.get('开户行')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}开户行为空;'.format(decision.get('自动化:审核说明'))}) + + case '开户行分行非空': + + result = self.is_string(content = data.get('开户行分行')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}开户行分行为空;'.format(decision.get('自动化:审核说明'))}) + + case '开户行分行所在省市非空': + + result = self.is_string(content = data.get('开户行分行所在省市')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}开户行分行为空;'.format(decision.get('自动化:审核说明'))}) + + case '开户行在指定范围之内': + + #根据保险分公司匹配开户行范围 + match slip_insurance_branch: + + case condition if '中银保险有限公司' in slip_insurance_branch: + + try: + + assert data.get('开户行') in banks.get('中银保险有限公司') + + result = 1 + + except: + + result = 0 + + case default: + + result = 0 + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}开户行不支持;'.format(decision.get('自动化:审核说明'))}) + + case '银行账户通过LUHN校验': + + result = self.is_bank_card(content = data.get('银行账户')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}银行账户有误;'.format(decision.get('自动化:审核说明'))}) + + case '银行账户通过LUHN校验(忽略18位)': + + result = self.is_bank_card(content = data.get('银行账户')) + + #24年11月13日就永诚黑龙江分公司跑批规则时,因赔案245005967032的银行账户为存折类故与业务商定若银行账户为18位则不校验 + if len(data.get('银行账户')) == 18: + + result = 1 + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}银行账户有误;'.format(decision.get('自动化:审核说明'))}) + + case '联系电话非空': + + result = self.is_string(content = data.get('联系电话')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}联系电话为空;'.format(decision.get('自动化:审核说明'))}) + + case '联系电话通过校验': + + result = self.is_mobile_phone(content = data.get('联系电话')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}联系电话有误;'.format(decision.get('自动化:审核说明'))}) + + case '联系地址非空': + + result = self.is_string(content = data.get('联系地址')) + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}联系地址为空;'.format(decision.get('自动化:审核说明'))}) + + case '赔付金额超过一万元联系地址非空': + + if data.get('理算金额') >= 10000: + + result = self.is_string(content = data.get('联系地址')) + + else: + + result = 1 + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}赔付金额超过一万元联系地址为空;'.format(decision.get('自动化:审核说明'))}) + + case '证件资料数大于等于2': + + try: + + assert len(data.get('证件影像件序号')) >= 2 + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}证件资料不全;'.format(decision.get('自动化:审核说明'))}) + + case '证件资料包含证件正面': + + try: + + assert '居民身份证正面' in data.get('证件影像件序号') + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}无证件正面;'.format(decision.get('自动化:审核说明'))}) + + case '证件资料包含证件背面': + + try: + + assert '居民身份证背面' in data.get('证件影像件序号') + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}无证件背面;'.format(decision.get('自动化:审核说明'))}) + + case '银行卡折资料数等于1': + + try: + + #若报案方式为线上报案则检查银行卡折资料数 + if data.get('报案方式') == '线上报案': + + assert len(data.get('银行卡折影像件序号')) == 1 + + result = 1 + + else: + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}银行卡折资料不全;'.format(decision.get('自动化:审核说明'))}) + + case '申请资料数等于1': + + try: + + assert len(data.get('申请书影像件序号')) == 1 + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}申请资料不全;'.format(decision.get('自动化:审核说明'))}) + + case '票据资料数大于等于1': + + try: + + assert len(data.get('票据影像件序号')) >= 1 + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据资料不全;'.format(decision.get('自动化:审核说明'))}) + + case '未分类资料数等于0': + + try: + + assert len(data.get('未分类影像件序号')) == 0 + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}有未分类影像件;'.format(decision.get('自动化:审核说明'))}) + + case '票据资料均已关联票据序号': + + try: + + #统计去重后影像件关联票据序号数和票据数是否相同(但是暂无法判断影像件关联票据序号是否正确) + assert len(list(set(data.get('票据影像件序号')))) == len([invoice.get('票据序号') for invoice in data.get('票据信息')]) + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据资料关联票据序号错误;'.format(decision.get('自动化:审核说明'))}) + + case '无重复票据提示': + + try: + + assert data.get('票据提示') == '无票据重复提示' + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}有票据提示;'.format(decision.get('自动化:审核说明'))}) + + case '票据交款人均为出险人': + + try: + + assert all([invoice.get('交款人') == data.get('出险人姓名') for invoice in data.get('票据信息')]) + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据交款人非出险人;'.format(decision.get('自动化:审核说明'))}) + + case '票据就诊类型均为药店购药': + + try: + + #断定票据就诊类型均非住院治疗 + assert all([invoice.get('就诊类型') == '药店购药' for invoice in data.get('票据信息')]) + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据就诊类型包括住院治疗;'.format(decision.get('自动化:审核说明'))}) + + case '票据就诊类型均非住院治疗': + + try: + + #断定票据就诊类型均非住院治疗 + assert all([invoice.get('就诊类型') != '住院治疗' for invoice in data.get('票据信息')]) + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据就诊类型包括住院治疗;'.format(decision.get('自动化:审核说明'))}) + + case '票据开具日期起期和开具日期止期均为同一日': + + try: + + #断定票据开具日期起期和止期均相同 + assert all([invoice.get('开具日期起期') == invoice.get('开具日期止期') for invoice in data.get('票据信息')]) + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据包含住院日期和出院日期非同一天;'.format(decision.get('自动化:审核说明'))}) + + case '票据诊断疾病均非空': + + try: + + assert all([self.is_string(invoice.get('诊断疾病')) == 1 for invoice in data.get('票据信息')]) + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据包含诊断疾病为空;'.format(decision.get('自动化:审核说明'))}) + + case '票据备注均非疾病': + + try: + + assert all(['非疾病' not in invoice.get('票据备注') for invoice in data.get('票据信息')]) + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据包含备注非疾病;'.format(decision.get('自动化:审核说明'))}) + + case '票据合理金额、部分自费和全部自费之和大于0': + + try: + + #统计所有票据合理金额之和 + assert sum([invoice.get('合理金额') + invoice.get('部分自费') + invoice.get('全部自费') for invoice in data.get('票据信息')]) > 0 + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据合理金额、部分自费和全部自费之和等于0;'.format(decision.get('自动化:审核说明'))}) + + case '票据关联责任均为同一类型': + + try: + + #统计去重后票据关联责任数是否等于1 + assert len(list(set([invoice.get('关联责任') for invoice in data.get('票据信息')]))) == 1 + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}票据关联多责任;'.format(decision.get('自动化:审核说明'))}) + + case '理算第一行保额大于0': + + try: + + assert data.get('理算第一行保额') > 0 + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}累计赔付已达到个人账户年度限额;'.format(decision.get('自动化:审核说明'))}) + + case '赔付金额小于3000': + + try: + + assert data.get('理算金额') < 3000 + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}理算金额大于等于3000元;'.format(decision.get('自动化:审核说明'))}) + + case '理算金额通过校验': + + try: + + assert data.get('理算金额') == data.get('公账赔付金额') + data.get('个账赔付金额') + + result = 1 + + except: + + result = 0 + + finally: + + decision['规则结果'].update({key: result}) + + if result == 0: + + decision.update({'自动化:审核说明': '{}理算金额未通过校验'.format(decision.get('自动化:审核说明'))}) + + #若所有规则结果为1则赔付,否则拒付 + if all([value == 1 for key, value in list(decision['规则结果'].items())]): + + decision.update({'自动化:审核结论': 1}) + + else: + + decision.update({'自动化:审核结论': 0}) + + return decision + + +