diff --git a/utils/operate.py b/utils/operate.py deleted file mode 100644 index e534604..0000000 --- a/utils/operate.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- - -""" - -脚本说明:基于MySQL、MongoDB、Request和飞书等API封装成常用功能 - -备注: -后续需要考虑优化,后续utils中脚本尽可能相互独立 - -""" - -# 导入模块 - -import json - - -import pandas - -import warnings - -import numpy - -from pydantic import BaseModel, ValidationError, AfterValidator, Field, HttpUrl - -from typing import Optional, Union, Unpack, Literal, Dict, TypedDict, Annotated - -from requests_toolbelt import MultipartEncoder - -import cv2 - -from requests import Session, Response - -from requests.adapters import HTTPAdapter - -from urllib.parse import ( - urlparse, - urlsplit, - urlunsplit, - parse_qs, - quote, - quote_plus, - unquote, - urlencode, -) - -from urllib.request import Request as request, urlopen - -from urllib.util.retry import Retry - -from urllib.error import HTTPError - -from pymongo import MongoClient - - -import os - -import threading - -import time - -from functools import wraps diff --git a/utils/rule_engine.py b/utils/rule_engine.py new file mode 100644 index 0000000..8da8a6f --- /dev/null +++ b/utils/rule_engine.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- + +""" +封装ZenEngine +""" + +# 导入模块 + +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any, Dict + +from zen import ZenDecision, ZenEngine + + +class RuleEngine: + """ + 规则引擎,实现打开并读取规则,根据规则和输入评估并输出 + """ + + def __init__(self, rules_path: Path): + """ + 初始化规则引擎 + :param rules_path: 规则文件夹路径(path对象) + """ + rules_path = Path(rules_path) + rules_path.mkdir(parents=True, exist_ok=True) # 若规则文件夹不存在则创建 + + # 初始化规则缓存 + self.decisions = {} + for decision_path in rules_path.glob("*.json"): + # 打开并读取规则文件并实例化规则 + self.decisions[decision_path.stem] = self._get_decision(decision_path) + + @staticmethod + def _get_decision(decision_path: Path) -> ZenDecision: + """ + 打开并读取规则文件并实例化规则 + :param decision_path: 规则文件路径(path对象) + :return: 实例化规则 + """ + + def loader(path): + with open(path, "r", encoding="utf-8") as file: + return file.read() + + return ZenEngine({"loader": loader}).get_decision(decision_path.as_posix()) + + def evaluate(self, decision: str, inputs: Dict[str, Any]) -> Dict[str, Any]: + """ + 调用规则并评估 + :param decision: 规则 + :param inputs: 输入 + :return: 输出 + """ + if decision not in self.decisions: + raise ValueError(f"规则不存在:{decision}") + + return self.decisions[decision].evaluate(self._format_value(inputs))["result"] + + def _format_value(self, value: Any) -> Any: + """ + 格式化值为字符串 + :param value: 值 + :return: 格式化后值 + """ + match value: + case int(): + return str(value) + case Decimal(): + # noinspection PyTypeChecker + return format(value, f".{abs(value.as_tuple().exponent)}f") + case datetime(): + return value.strftime("%Y-%m-%d %H:%M:%S") + case list(): + return [self._format_value(e) for e in value] + case dict(): + return {k: self._format_value(v) for k, v in value.items()} + case _: + return value diff --git a/普康健康审核机器人/pageobject.py b/普康健康审核机器人/pageobject.py index c6677c0..a8a0d85 100644 --- a/普康健康审核机器人/pageobject.py +++ b/普康健康审核机器人/pageobject.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -''' +""" 脚本说明:基于Selenium封装常用页面操作,例如在当前标签页打开链接 @@ -8,45 +8,42 @@ 普康健康自动审核尚未拆解动作 -''' +""" -#导入模块 - -from urllib.parse import urlparse - -from selenium.webdriver import ChromeService, ChromeOptions, Chrome - -from selenium.webdriver.support.wait import WebDriverWait - -from selenium.webdriver.support.expected_conditions import presence_of_element_located, presence_of_all_elements_located, element_to_be_clickable, text_to_be_present_in_element, title_is - -from selenium.webdriver.common.by import By - -import re - -import time - -from datetime import datetime +# 导入模块 import json - -#导入普康自动化审核决策模型 -from cognition import Cognition - import os - +import re import sys +import time +from datetime import datetime +from urllib.parse import urlparse + +from selenium.webdriver import Chrome, ChromeOptions, ChromeService +from selenium.webdriver.common.by import By +from selenium.webdriver.support.expected_conditions import ( + element_to_be_clickable, + presence_of_all_elements_located, + presence_of_element_located, + text_to_be_present_in_element, + title_is, +) +from selenium.webdriver.support.wait import WebDriverWait + +# 导入普康自动化审核决策模型 +from cognition import Cognition sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) from utils.logger import Logger -from utils.operate import FeishuMail +from utils.rule_engine import FeishuMail -#创建日志记录器 -logger = Logger(logger_name = 'pageobject').get_logger() +# 创建日志记录器 +logger = Logger(logger_name="pageobject").get_logger() -''' +""" 函数说明:初始化浏览器 @@ -54,1326 +51,1877 @@ logger = Logger(logger_name = 'pageobject').get_logger() 暂仅支持一个浏览器包括一个窗口,一个窗口若干标签页(BrowserTab) -''' +""" + def Browser(): - #使用本地浏览器 - service = ChromeService(executable_path = '/usr/local/bin/chromedriver') + # 使用本地浏览器 + service = ChromeService(executable_path="/usr/local/bin/chromedriver") - #设置浏览器参数 - options = ChromeOptions() + # 设置浏览器参数 + options = ChromeOptions() - #浏览器启用无头模式 - #options.add_argument('--headless') + # 浏览器启用无头模式 + # options.add_argument('--headless') - #初始化浏览器 - browser = Chrome(service = service, options = options) + # 初始化浏览器 + browser = Chrome(service=service, options=options) - #最大化浏览器窗口 - browser.maximize_window() + # 最大化浏览器窗口 + browser.maximize_window() - return browser + return browser -''' + +""" 类说明:自定义页面对象模型 -''' +""" + class PageObject: - def __init__(self): + def __init__(self): - #实例化浏览 - self.browser = Browser() + # 实例化浏览 + self.browser = Browser() - #隐式等待,超时时间设置为30秒,检查间隔设置为1秒 - #self.browser.implicitly_wait(timeout = 30, poll_frequency = 1) + # 隐式等待,超时时间设置为30秒,检查间隔设置为1秒 + # self.browser.implicitly_wait(timeout = 30, poll_frequency = 1) - #显式等待,超时时间设置为30秒,检查间隔设置为1秒 - self.wait = WebDriverWait(driver = self.browser, timeout = 30, poll_frequency = 1) + # 显式等待,超时时间设置为30秒,检查间隔设置为1秒 + self.wait = WebDriverWait(driver=self.browser, timeout=30, poll_frequency=1) - #用于记录已完成任务数,一次启动可执行多个任务 - self.tasks = 0 + # 用于记录已完成任务数,一次启动可执行多个任务 + self.tasks = 0 - #用于保存在任务执行过程中抽取的内容 - self.data = [] + # 用于保存在任务执行过程中抽取的内容 + self.data = [] - #用于保存所有抽取内容 - self.dataset = [] - - #在当前标签页打开链接 - def open_link(self, url): + # 用于保存所有抽取内容 + self.dataset = [] - #断定链接是否包含协议和网络位置 - assert urlparse(url).scheme and urlparse(url).netloc, 'url invalid' + # 在当前标签页打开链接 + def open_link(self, url): - self.browser.get(url) + # 断定链接是否包含协议和网络位置 + assert urlparse(url).scheme and urlparse(url).netloc, "url invalid" - time.sleep(1) + self.browser.get(url) - #点击(如无特殊说明使用XPATH定位元素) - def click(self, xpath): + time.sleep(1) - #尝试等待定位元素可点击再使用CLIKC方法点击,若超时或发生其它异常则等待定位元素可见再使用JAVASCRIPT方法点击 - try: + # 点击(如无特殊说明使用XPATH定位元素) + def click(self, xpath): - element = self.wait.until(presence_of_element_located((By.XPATH, xpath))) + # 尝试等待定位元素可点击再使用CLIKC方法点击,若超时或发生其它异常则等待定位元素可见再使用JAVASCRIPT方法点击 + try: - element.click() + element = self.wait.until(presence_of_element_located((By.XPATH, xpath))) - time.sleep(1) + element.click() - except: + time.sleep(1) - try: + except: - element = self.wait.until(presence_of_element_located((By.XPATH, xpath))) + try: - self.browser.execute_script('arguments[0].click();', element) + element = self.wait.until( + presence_of_element_located((By.XPATH, xpath)) + ) - time.sleep(1) + self.browser.execute_script("arguments[0].click();", element) - except Exception as exception: + time.sleep(1) - raise exception + except Exception as exception: - #点击并切换至新标签页 - def click_and_switch(self, xpath): + raise exception - #获取点击前所有标签页句柄 - window_handles = self.browser.window_handles + # 点击并切换至新标签页 + def click_and_switch(self, xpath): - self.click(xpath = xpath) + # 获取点击前所有标签页句柄 + window_handles = self.browser.window_handles - #等待至点击后所有标签页句柄数等于点击前加一 - self.wait.until(lambda condition: len(self.browser.window_handles) == len(window_handles) + 1) + self.click(xpath=xpath) - #获取新标签页句柄(暂仅支持点击新建一个标签页的场景) - new_window_handle = [window_handle for window_handle in self.browser.window_handles if window_handle not in window_handles][0] + # 等待至点击后所有标签页句柄数等于点击前加一 + self.wait.until( + lambda condition: len(self.browser.window_handles) + == len(window_handles) + 1 + ) - #切换至新标签页 - self.browser.switch_to.window(new_window_handle) + # 获取新标签页句柄(暂仅支持点击新建一个标签页的场景) + new_window_handle = [ + window_handle + for window_handle in self.browser.window_handles + if window_handle not in window_handles + ][0] - time.sleep(1) + # 切换至新标签页 + self.browser.switch_to.window(new_window_handle) - #选择(适用于非HTML原生SELECT标签的场景) - def select(self, xpaths): + time.sleep(1) - for xpath in xpaths: + # 选择(适用于非HTML原生SELECT标签的场景) + def select(self, xpaths): - element = self.wait.until(presence_of_element_located((By.XPATH, xpath))) + for xpath in xpaths: - self.browser.execute_script('arguments[0].click();', element) + element = self.wait.until(presence_of_element_located((By.XPATH, xpath))) - time.sleep(1) + self.browser.execute_script("arguments[0].click();", element) - #输入 - def input(self, xpath, content): + time.sleep(1) - #等待至定位元素出现 - element = self.wait.until(presence_of_element_located((By.XPATH, xpath))) + # 输入 + def input(self, xpath, content): - #清除定位元素原内容 - element.clear() + # 等待至定位元素出现 + element = self.wait.until(presence_of_element_located((By.XPATH, xpath))) - #使用SENDKEYS方法输入 - element.send_keys(content) + # 清除定位元素原内容 + element.clear() - time.sleep(1) + # 使用SENDKEYS方法输入 + element.send_keys(content) - #关闭模态弹窗(适用于非HTML原生DIALOG弹窗开发场景) - #需要优化!!! - def close_dialog(self): + time.sleep(1) - #等待至焦点元素文本为指定内容 - self.wait.until(lambda condition: self.browser.execute_script('return document.activeElement;').text.replace(' ', '') in ['确定', '确认', '关闭']) + # 关闭模态弹窗(适用于非HTML原生DIALOG弹窗开发场景) + # 需要优化!!! + def close_dialog(self): - #使用JAVASCRIPT方法获取焦点元素 - element = self.browser.execute_script('return document.activeElement;') + # 等待至焦点元素文本为指定内容 + self.wait.until( + lambda condition: self.browser.execute_script( + "return document.activeElement;" + ).text.replace(" ", "") + in ["确定", "确认", "关闭"] + ) - #使用JAVASCRIPT方法点击 - element.click() + # 使用JAVASCRIPT方法获取焦点元素 + element = self.browser.execute_script("return document.activeElement;") - time.sleep(1) + # 使用JAVASCRIPT方法点击 + element.click() - #关闭当前标签页并切换至上一标签页 - def close_and_switch(self): + time.sleep(1) - #获取关闭当前标签页前所有标签页句柄 - window_handles = self.browser.window_handles + # 关闭当前标签页并切换至上一标签页 + def close_and_switch(self): - #若关闭当前标签页前所有标签页句柄数大于等于2则获取上一标签页句柄、关闭当前标签页并切换至上一标签页,否则切换至第一标签页 - if len(window_handles) >= 2: + # 获取关闭当前标签页前所有标签页句柄 + window_handles = self.browser.window_handles - current_window_handle = self.browser.current_window_handle + # 若关闭当前标签页前所有标签页句柄数大于等于2则获取上一标签页句柄、关闭当前标签页并切换至上一标签页,否则切换至第一标签页 + if len(window_handles) >= 2: - target_window_handle = [window_handle for window_handle in window_handles if window_handle != current_window_handle][-1] + current_window_handle = self.browser.current_window_handle - #关闭当前标签页 - self.browser.close() + target_window_handle = [ + window_handle + for window_handle in window_handles + if window_handle != current_window_handle + ][-1] - #切换至上一标签页 - self.browser.switch_to.window(target_window_handle) + # 关闭当前标签页 + self.browser.close() - time.sleep(1) + # 切换至上一标签页 + self.browser.switch_to.window(target_window_handle) - else: + time.sleep(1) - self.browser.switch_to.window(window_handles[0]) + else: - #关闭除第一标签页以外的所有标签页并切换至第一标签页 - def close_and_switch_to_first(self): + self.browser.switch_to.window(window_handles[0]) - #获取关闭前所有标签页句柄 - window_handles = self.browser.window_handles + # 关闭除第一标签页以外的所有标签页并切换至第一标签页 + def close_and_switch_to_first(self): - #根据标签页句柄数减1关闭当前标签页并切换至上一标签页 - for index in range(len(window_handles) - 1): + # 获取关闭前所有标签页句柄 + window_handles = self.browser.window_handles - self.close_and_switch() + # 根据标签页句柄数减1关闭当前标签页并切换至上一标签页 + for index in range(len(window_handles) - 1): - #抽取数据 - def extract(self, extractions): + self.close_and_switch() - #遍历抽取内容 - #抽取内容包括两种类型,一种针对字段,另一种针对表格 - for extraction in extractions: + # 抽取数据 + def extract(self, extractions): - #考虑部分字段在页面中不存在,使用直接定位元素 - try: + # 遍历抽取内容 + # 抽取内容包括两种类型,一种针对字段,另一种针对表格 + for extraction in extractions: - #针对抽取字段 - if isinstance(extraction.get('field'), str): + # 考虑部分字段在页面中不存在,使用直接定位元素 + try: - #正则匹配字段XPATH最后一个/后面的内容 - matcher = re.search(r'/([^/]*)$', extraction.get('field_xpath')).group(1) + # 针对抽取字段 + if isinstance(extraction.get("field"), str): - #根据正则匹配结果匹配解析方法 - match matcher: + # 正则匹配字段XPATH最后一个/后面的内容 + matcher = re.search( + r"/([^/]*)$", extraction.get("field_xpath") + ).group(1) - case matcher if 'input' in matcher: + # 根据正则匹配结果匹配解析方法 + match matcher: - content = self.browser.find_element(By.XPATH, extraction.get('field_xpath')).get_attribute('value') + case matcher if "input" in matcher: - case default: + content = self.browser.find_element( + By.XPATH, extraction.get("field_xpath") + ).get_attribute("value") - content = self.browser.find_element(By.XPATH, extraction.get('field_xpath')).text + case default: - #针对抽取表格字段 - if isinstance(extraction.get('table'), str): + content = self.browser.find_element( + By.XPATH, extraction.get("field_xpath") + ).text - content = [] + # 针对抽取表格字段 + if isinstance(extraction.get("table"), str): - #遍历表格 - for row_index in range(1, int(self.browser.find_element(By.XPATH, extraction.get('table_xpath')).get_attribute('childElementCount')) + 1): + content = [] - row_contents = {} + # 遍历表格 + for row_index in range( + 1, + int( + self.browser.find_element( + By.XPATH, extraction.get("table_xpath") + ).get_attribute("childElementCount") + ) + + 1, + ): - #遍历表格字段 - for field in extraction.get('fields'): + row_contents = {} - #先尝试使用字段XPATH定位并解析内容,若为空字符则尝试定位至INPUT标签解析内容 - try: + # 遍历表格字段 + for field in extraction.get("fields"): - #基于模版生成字段XPATH - field_xpath = field.get('field_xpath').replace('[index]', '[{}]'.format(row_index)) + # 先尝试使用字段XPATH定位并解析内容,若为空字符则尝试定位至INPUT标签解析内容 + try: - field_content = self.browser.find_element(By.XPATH, field_xpath).text + # 基于模版生成字段XPATH + field_xpath = field.get("field_xpath").replace( + "[index]", "[{}]".format(row_index) + ) - if field_content == '': + field_content = self.browser.find_element( + By.XPATH, field_xpath + ).text - #定位至INPUT标签 - field_xpath = '{}//input'.format(field_xpath) + if field_content == "": - field_content = self.browser.find_element(By.XPATH, field_xpath).get_attribute('value') + # 定位至INPUT标签 + field_xpath = "{}//input".format(field_xpath) - except: + field_content = self.browser.find_element( + By.XPATH, field_xpath + ).get_attribute("value") - field_content = '' + except: - finally: + field_content = "" - row_contents.update({field.get('field'): field_content}) + finally: - content.append(row_contents) + row_contents.update({field.get("field"): field_content}) - except: + content.append(row_contents) - content = '' + except: - #保存抽取内容 - finally: + content = "" - if isinstance(extraction.get('field'), str): + # 保存抽取内容 + finally: - self.data.append({extraction.get('field'): content}) + if isinstance(extraction.get("field"), str): - if isinstance(extraction.get('table'), str): + self.data.append({extraction.get("field"): content}) - self.data.append({extraction.get('table'): content}) + if isinstance(extraction.get("table"), str): - #普康健康-等待至模态弹窗标题是否包含约定,若包含则关闭模态弹窗,若不包含则跳过 - def close_stipulation(self, action): + self.data.append({extraction.get("table"): content}) - logger.info('正在等待 模态弹窗标题 是否包含 约定') + # 普康健康-等待至模态弹窗标题是否包含约定,若包含则关闭模态弹窗,若不包含则跳过 + def close_stipulation(self, action): - try: + logger.info("正在等待 模态弹窗标题 是否包含 约定") - #等待至模态弹窗标题包含约定 - WebDriverWait(driver = self.browser, timeout = 5, poll_frequency = 1).until(lambda condition: '约定' in self.browser.find_element(By.XPATH, action.get('text_stipulation_xpath')).text) + try: - logger.info('关闭模态弹窗') + # 等待至模态弹窗标题包含约定 + WebDriverWait(driver=self.browser, timeout=5, poll_frequency=1).until( + lambda condition: "约定" + in self.browser.find_element( + By.XPATH, action.get("text_stipulation_xpath") + ).text + ) - self.click(xpath = action.get('button_close_stipulation_xpath')) + logger.info("关闭模态弹窗") - except: + self.click(xpath=action.get("button_close_stipulation_xpath")) - logger.info('继续') + except: - #普康健康-拒付票据 - def invoices_refuse(self, action, insurance, cognition = None): + logger.info("继续") - logger.info('正在点击: 修改信息按钮') + # 普康健康-拒付票据 + def invoices_refuse(self, action, insurance, cognition=None): - self.click(xpath = action.get('button_modify_xpath')) + logger.info("正在点击: 修改信息按钮") - #根据保险总公司匹配点击修改信息按钮之后的动作 - match insurance: + self.click(xpath=action.get("button_modify_xpath")) - #使用瑞泰审核页面 - case '瑞泰人寿保险有限公司': + # 根据保险总公司匹配点击修改信息按钮之后的动作 + match insurance: - logger.info('正在选择: 差错原因') + # 使用瑞泰审核页面 + case "瑞泰人寿保险有限公司": - self.select(xpaths = action.get('droplist_modify_xpath')) + logger.info("正在选择: 差错原因") - logger.info('正在输入: 原因说明 自动化处理') + self.select(xpaths=action.get("droplist_modify_xpath")) - self.input(xpath = action.get('textarea_modify_xpath'), content = '自动化处理') + logger.info("正在输入: 原因说明 自动化处理") - logger.info('正在点击: 确定按钮') + self.input( + xpath=action.get("textarea_modify_xpath"), content="自动化处理" + ) - self.click(xpath = action.get('button_modification_confirm_xpath')) + logger.info("正在点击: 确定按钮") - #等待至赔案号加载完成 - self.wait.until(lambda condition: presence_of_element_located((By.XPATH, action.get('field_case_number_xpath')))) + self.click(xpath=action.get("button_modification_confirm_xpath")) - #等待至票据表格所有元素加载完成 - element = self.wait.until(presence_of_element_located((By.XPATH, action.get('table_invoices_xpath')))) + # 等待至赔案号加载完成 + self.wait.until( + lambda condition: presence_of_element_located( + (By.XPATH, action.get("field_case_number_xpath")) + ) + ) - #解析票据行数 - indices = int(element.get_attribute('childElementCount')) + # 等待至票据表格所有元素加载完成 + element = self.wait.until( + presence_of_element_located((By.XPATH, action.get("table_invoices_xpath"))) + ) - #若COGNITION为空则创建所有的票据索引 - if cognition is None: + # 解析票据行数 + indices = int(element.get_attribute("childElementCount")) - cognition = {'拒付票据索引': {'所有的票据索引': [index + 1 for index in range(indices)]}} + # 若COGNITION为空则创建所有的票据索引 + if cognition is None: - #遍历票据表格索引 - for index in range(indices): + cognition = { + "拒付票据索引": { + "所有的票据索引": [index + 1 for index in range(indices)] + } + } - index += 1 + # 遍历票据表格索引 + for index in range(indices): - #遍历需要拒付的票据索引键值对 - for key, value in list(cognition.get('拒付票据索引').items()): + index += 1 - #若票据索引在需要拒付的票据索引中则拒付该张票据 - if index in cognition.get('拒付票据索引').get(key): + # 遍历需要拒付的票据索引键值对 + for key, value in list(cognition.get("拒付票据索引").items()): - #等待至索引行元素加载完成 - element = self.wait.until(presence_of_element_located((By.XPATH, action.get('field_invoice_identifier_xpath').replace('tr[index]', 'tr[{}]'.format(index)).rsplit('/', 1)[0]))) + # 若票据索引在需要拒付的票据索引中则拒付该张票据 + if index in cognition.get("拒付票据索引").get(key): - #将索引行移动至可见 - self.browser.execute_script("arguments[0].scrollIntoView(true);", element) + # 等待至索引行元素加载完成 + element = self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get("field_invoice_identifier_xpath") + .replace("tr[index]", "tr[{}]".format(index)) + .rsplit("/", 1)[0], + ) + ) + ) + + # 将索引行移动至可见 + self.browser.execute_script( + "arguments[0].scrollIntoView(true);", element + ) + + # 点击索引行的行唯一标识 + self.click( + xpath=action.get("field_invoice_identifier_xpath").replace( + "[index]", "[{}]".format(index) + ) + ) + + # 解析合理金额 + reasonable_amounts = float( + self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get( + "field_reasonable_amounts_xpath" + ).replace("[index]", "[{}]".format(index)), + ) + ) + ).text + ) - #点击索引行的行唯一标识 - self.click(xpath = action.get('field_invoice_identifier_xpath').replace('[index]', '[{}]'.format(index))) + # 解析部分自费 + part_self_amounts = float( + self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get("field_part_self_amounts_xpath").replace( + "[index]", "[{}]".format(index) + ), + ) + ) + ).text + ) + + # 解析全部自费 + all_self_amounts = float( + self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get("field_all_self_amounts_xpath").replace( + "[index]", "[{}]".format(index) + ), + ) + ) + ).text + ) + + # 解析不合理金额 + unreasonable_amounts = ( + reasonable_amounts + part_self_amounts + all_self_amounts + ) - #解析合理金额 - reasonable_amounts = float(self.wait.until(presence_of_element_located((By.XPATH, action.get('field_reasonable_amounts_xpath').replace('[index]', '[{}]'.format(index))))).text) + if unreasonable_amounts != 0: - #解析部分自费 - part_self_amounts = float(self.wait.until(presence_of_element_located((By.XPATH, action.get('field_part_self_amounts_xpath').replace('[index]', '[{}]'.format(index))))).text) + logger.info("拒付第 {} 张票据".format(index)) - #解析全部自费 - all_self_amounts = float(self.wait.until(presence_of_element_located((By.XPATH, action.get('field_all_self_amounts_xpath').replace('[index]', '[{}]'.format(index))))).text) + logger.info("正在点击: 修改按钮") - #解析不合理金额 - unreasonable_amounts = reasonable_amounts + part_self_amounts + all_self_amounts + self.click( + xpath=action.get("button_invoice_modify_xpath").replace( + "[index]", "[{}]".format(index) + ) + ) - if unreasonable_amounts != 0: + logger.info("正在输入: 部分自费 0") + + self.input( + xpath=action.get("input_invoice_part_self_xpath").replace( + "[index]", "[{}]".format(index) + ), + content=0, + ) + + logger.info("正在输入: 全部自费 0") + + self.input( + xpath=action.get("input_invoice_all_self_xpath").replace( + "[index]", "[{}]".format(index) + ), + content=0, + ) + + logger.info( + "正在输入: 不合理金额 {}".format(unreasonable_amounts) + ) - logger.info('拒付第 {} 张票据'.format(index)) + self.input( + xpath=action.get( + "input_invoice_unreasonable_xpath" + ).replace("[index]", "[{}]".format(index)), + content=unreasonable_amounts, + ) - logger.info('正在点击: 修改按钮') + match key: - self.click(xpath = action.get('button_invoice_modify_xpath').replace('[index]', '[{}]'.format(index))) + case "不在保单保障期的票据索引": - logger.info('正在输入: 部分自费 0') + content = "不在保单保障期歉难给付" - self.input(xpath = action.get('input_invoice_part_self_xpath').replace('[index]', '[{}]'.format(index)), content = 0) + case "交款人非出险人的票据索引": - logger.info('正在输入: 全部自费 0') + content = "非本人发票歉难给付" - self.input(xpath = action.get('input_invoice_all_self_xpath').replace('[index]', '[{}]'.format(index)), content = 0) + case "已验换开的票据索引": - logger.info('正在输入: 不合理金额 {}'.format(unreasonable_amounts)) + content = "已验换开歉难给付" - self.input(xpath = action.get('input_invoice_unreasonable_xpath').replace('[index]', '[{}]'.format(index)), content = unreasonable_amounts) + case "已验红冲的票据索引": - match key: + content = "已验红冲歉难给付" - case '不在保单保障期的票据索引': + case "已验假票的票据索引": - content = '不在保单保障期歉难给付' + content = "已验假票歉难给付" - case '交款人非出险人的票据索引': + case "无法验真的票据索引": - content = '非本人发票歉难给付' + content = "无法验真歉难给付" - case '已验换开的票据索引': + case default: - content = '已验换开歉难给付' + content = "" - case '已验红冲的票据索引': + logger.info("正在输入: 票据备注 {}".format(content)) - content = '已验红冲歉难给付' + self.input( + xpath=action.get("input_invoice_remark_xpath").replace( + "[index]", "[{}]".format(index) + ), + content=content, + ) - case '已验假票的票据索引': + logger.info("正在点击: 确定按钮") - content = '已验假票歉难给付' + self.click( + xpath=action.get("button_invoice_confirm_xpath").replace( + "[index]", "[{}]".format(index) + ) + ) - case '无法验真的票据索引': + logger.info("正在点击: 保存按钮") - content = '无法验真歉难给付' + self.click(xpath=action.get("button_save_xpath")) - case default: + logger.info("正在关闭模态弹窗: 保存确认弹窗") - content = '' + self.close_dialog() - logger.info('正在输入: 票据备注 {}'.format(content)) + logger.info("正在点击: 确认修改按钮") - self.input(xpath = action.get('input_invoice_remark_xpath').replace('[index]', '[{}]'.format(index)), content = content) + self.click(xpath=action.get("button_confirm_xpath")) - logger.info('正在点击: 确定按钮') + # 普康健康选择保单 + def slip_select(self, action, slip_index): - self.click(xpath = action.get('button_invoice_confirm_xpath').replace('[index]', '[{}]'.format(index))) - - logger.info('正在点击: 保存按钮') + logger.info("正在判断 保单是否已选择") - self.click(xpath = action.get('button_save_xpath')) + # 等待至所选保单复选框元素加载完成 + element = self.wait.until( + presence_of_element_located( + ( + By.XPATH, + "{}/span/input".format( + action.get("checkbox_select_xpath").replace( + "[index]", "[{}]".format(slip_index) + ) + ), + ) + ) + ) - logger.info('正在关闭模态弹窗: 保存确认弹窗') + # 若应选保单复选框已选择则跳过,否则选择 + if element.get_attribute("checked") != "true": - self.close_dialog() + logger.info("否,选择保单") - logger.info('正在点击: 确认修改按钮') + self.click( + xpath=action.get("checkbox_select_xpath").replace( + "[index]", "[{}]".format(slip_index) + ) + ) - self.click(xpath = action.get('button_confirm_xpath')) + logger.info("正在关闭模态弹窗: 选择保单确认弹窗") - #普康健康选择保单 - def slip_select(self, action, slip_index): + self.close_dialog() - logger.info('正在判断 保单是否已选择') + logger.info("正在等待提示选择保单成功") - #等待至所选保单复选框元素加载完成 - element = self.wait.until(presence_of_element_located((By.XPATH, '{}/span/input'.format(action.get('checkbox_select_xpath').replace('[index]', '[{}]'.format(slip_index)))))) + self.close_stipulation(action=action) - #若应选保单复选框已选择则跳过,否则选择 - if element.get_attribute('checked') != 'true': + # 等待至赔案号加载完成 + self.wait.until( + lambda condition: presence_of_element_located( + (By.XPATH, action.get("field_case_number_xpath")) + ) + ) - logger.info('否,选择保单') + # 等待至票据表格所有元素加载完成 + WebDriverWait(driver=self.browser, timeout=60, poll_frequency=1).until( + lambda condition: presence_of_all_elements_located( + (By.XPATH, action.get("table_invoices_xpath")) + ) + ) - self.click(xpath = action.get('checkbox_select_xpath').replace('[index]', '[{}]'.format(slip_index))) + else: - logger.info('正在关闭模态弹窗: 选择保单确认弹窗') + logger.info("是,继续") - self.close_dialog() + self.click( + xpath=action.get("checkbox_select_xpath").replace( + "[index]", "[{}]".format(slip_index) + ) + ) - logger.info('正在等待提示选择保单成功') + self.click( + xpath=action.get("checkbox_select_xpath").replace( + "[index]", "[{}]".format(slip_index) + ) + ) - self.close_stipulation(action = action) + logger.info("正在关闭模态弹窗: 选择保单确认弹窗") - #等待至赔案号加载完成 - self.wait.until(lambda condition: presence_of_element_located((By.XPATH, action.get('field_case_number_xpath')))) + self.close_dialog() - #等待至票据表格所有元素加载完成 - WebDriverWait(driver = self.browser, timeout = 60, poll_frequency = 1).until(lambda condition: presence_of_all_elements_located((By.XPATH, action.get('table_invoices_xpath')))) + logger.info("正在等待提示选择保单成功") - else: + self.close_stipulation(action=action) - logger.info('是,继续') + # 等待至赔案号加载完成 + self.wait.until( + lambda condition: presence_of_element_located( + (By.XPATH, action.get("field_case_number_xpath")) + ) + ) - self.click(xpath = action.get('checkbox_select_xpath').replace('[index]', '[{}]'.format(slip_index))) + # 等待至票据表格所有元素加载完成 + WebDriverWait(driver=self.browser, timeout=60, poll_frequency=1).until( + lambda condition: presence_of_all_elements_located( + (By.XPATH, action.get("table_invoices_xpath")) + ) + ) - self.click(xpath = action.get('checkbox_select_xpath').replace('[index]', '[{}]'.format(slip_index))) + # 普康健康报案 + def case_report(self, action, insurance, cognition): - logger.info('正在关闭模态弹窗: 选择保单确认弹窗') + logger.info("正在判断 该赔案是否需要报案") - self.close_dialog() + if cognition.get("在线报案"): - logger.info('正在等待提示选择保单成功') + logger.info("该赔案需要在线报案") - self.close_stipulation(action = action) + logger.info("正在点击: 在线报案按钮") - #等待至赔案号加载完成 - self.wait.until(lambda condition: presence_of_element_located((By.XPATH, action.get('field_case_number_xpath')))) + self.click(xpath=action.get("button_report_xpath")) - #等待至票据表格所有元素加载完成 - WebDriverWait(driver = self.browser, timeout = 60, poll_frequency = 1).until(lambda condition: presence_of_all_elements_located((By.XPATH, action.get('table_invoices_xpath')))) + logger.info("正在点击: 确定按钮") - #普康健康报案 - def case_report(self, action, insurance, cognition): + self.click(xpath=action.get("button_report_confirm_xpath")) - logger.info('正在判断 该赔案是否需要报案') + logger.info("正在等待提示在线报案成功") - if cognition.get('在线报案'): + # 等待至提示选择报案成功 + WebDriverWait(driver=self.browser, timeout=10, poll_frequency=0.2).until( + lambda condition: "成功" + in self.browser.find_element(By.XPATH, action.get("toast_report")).text + ) - logger.info('该赔案需要在线报案') + else: - logger.info('正在点击: 在线报案按钮') + logger.info("该赔案无需在线报案,继续") - self.click(xpath = action.get('button_report_xpath')) + # 普康健康理算 + def adjust(self, action): - logger.info('正在点击: 确定按钮') + logger.info("正在点击: 理算按钮") - self.click(xpath = action.get('button_report_confirm_xpath')) + self.click(xpath=action.get("button_adjust_xpath")) - logger.info('正在等待提示在线报案成功') + logger.info("正在关闭模态弹窗: 理算确认弹窗") - #等待至提示选择报案成功 - WebDriverWait(driver = self.browser, timeout = 10, poll_frequency = 0.2).until(lambda condition: '成功' in self.browser.find_element(By.XPATH, action.get('toast_report')).text) + self.close_dialog() - else: + logger.info("正在判断 模态弹窗标题 是否包含 警告") - logger.info('该赔案无需在线报案,继续') + try: - #普康健康理算 - def adjust(self, action): + # 等待至等待至模态弹窗标题包含警告(理算锁,该主被保险人存在未审核赔案,该赔案无法理算) + WebDriverWait(driver=self.browser, timeout=3, poll_frequency=1).until( + lambda condition: "警告" + in self.browser.find_element( + By.XPATH, action.get("text_caution_xpath") + ).text + ) - logger.info('正在点击: 理算按钮') + logger.info("是,跳过该赔案") - self.click(xpath = action.get('button_adjust_xpath')) + return False - logger.info('正在关闭模态弹窗: 理算确认弹窗') + except: - self.close_dialog() + logger.info("否,继续") - logger.info('正在判断 模态弹窗标题 是否包含 警告') - - try: + logger.info("正在判断 模态弹窗标题 是否包含 不一致") - #等待至等待至模态弹窗标题包含警告(理算锁,该主被保险人存在未审核赔案,该赔案无法理算) - WebDriverWait(driver = self.browser, timeout = 3, poll_frequency = 1).until(lambda condition: '警告' in self.browser.find_element(By.XPATH, action.get('text_caution_xpath')).text) + try: - logger.info('是,跳过该赔案') + # 等待至等待至模态弹窗标题包含不一致(例如,票据交款人与出险人不一致) + WebDriverWait(driver=self.browser, timeout=3, poll_frequency=1).until( + lambda condition: "不一致" + in self.browser.find_element( + By.XPATH, action.get("text_caution_xpath") + ).text + ) - return False + logger.info("是,关闭模态弹窗") - except: + self.click(xpath=action.get("button_close_caution_xpath")) - logger.info('否,继续') + except: - logger.info('正在判断 模态弹窗标题 是否包含 不一致') + logger.info("否,继续") - try: + logger.info("正在等待提示理算成功") - #等待至等待至模态弹窗标题包含不一致(例如,票据交款人与出险人不一致) - WebDriverWait(driver = self.browser, timeout = 3, poll_frequency = 1).until(lambda condition: '不一致' in self.browser.find_element(By.XPATH, action.get('text_caution_xpath')).text) + # 等待至赔案号加载完成 + self.wait.until( + lambda condition: presence_of_element_located( + (By.XPATH, action.get("field_case_number_xpath")) + ) + ) - logger.info('是,关闭模态弹窗') + # 等待至票据表格所有元素加载完成 + WebDriverWait(driver=self.browser, timeout=60, poll_frequency=1).until( + lambda condition: presence_of_all_elements_located( + (By.XPATH, action.get("table_invoices_xpath")) + ) + ) - self.click(xpath = action.get('button_close_caution_xpath')) + # 等待至理算表格所有元素加载完成 + WebDriverWait(driver=self.browser, timeout=60, poll_frequency=1).until( + lambda condition: presence_of_all_elements_located( + (By.XPATH, action.get("table_adjustment_xpath")) + ) + ) - except: + self.close_stipulation(action=action) - logger.info('否,继续') + return True - logger.info('正在等待提示理算成功') + # 动作解释器。其中,actions为动作组,index为实际索引、默认为空 + def translator(self, actions, index=None): - #等待至赔案号加载完成 - self.wait.until(lambda condition: presence_of_element_located((By.XPATH, action.get('field_case_number_xpath')))) + # 遍历动作 + for action in actions: - #等待至票据表格所有元素加载完成 - WebDriverWait(driver = self.browser, timeout = 60, poll_frequency = 1).until(lambda condition: presence_of_all_elements_located((By.XPATH, action.get('table_invoices_xpath')))) + # 若实际索引数据类型为整数且包含“[INDEX]”且对象不包含“EXECUTE:”则将其替换为实际索引 + try: - #等待至理算表格所有元素加载完成 - WebDriverWait(driver = self.browser, timeout = 60, poll_frequency = 1).until(lambda condition: presence_of_all_elements_located((By.XPATH, action.get('table_adjustment_xpath')))) + assert ( + isinstance(index, int) + and "[index]" in action.get("object") + and "execute:" not in action.get("object") + ) - self.close_stipulation(action = action) + object_ = action.get("object").replace("[index]", "[{}]".format(index)) - return True + except: - #动作解释器。其中,actions为动作组,index为实际索引、默认为空 - def translator(self, actions, index = None): + object_ = action.get("object") - #遍历动作 - for action in actions: + # 根据动作类型匹配动作内容 + match action.get("action_type"): - #若实际索引数据类型为整数且包含“[INDEX]”且对象不包含“EXECUTE:”则将其替换为实际索引 - try: + # 在当前标签页打开链接 + # 动作配置项须包含action_type和object + case "open_link": - assert isinstance(index, int) and '[index]' in action.get('object') and 'execute:' not in action.get('object') + logger.info("正在当前标签页打开链接: {}".format(object_)) - object_ = action.get('object').replace('[index]', '[{}]'.format(index)) + self.open_link(url=object_) - except: + # 点击 + # 动作配置项须包含action_type、object_name和object,若first_row_identifier_xpath数据类型为字符其非空字符则先解析再点击,等待条件为第一行唯一标识与点击之前不相同 + case "click": - object_ = action.get('object') + logger.info("正在点击: {}".format(action.get("object_name"))) - #根据动作类型匹配动作内容 - match action.get('action_type'): + if ( + isinstance(action.get("first_row_identifier_xpath"), str) + and action.get("first_row_identifier_xpath") != "" + ): - #在当前标签页打开链接 - #动作配置项须包含action_type和object - case 'open_link': + # 解析点击之前第一行唯一标识 + first_row_identifier = self.browser.find_element( + By.XPATH, action.get("first_row_identifier_xpath") + ).text - logger.info('正在当前标签页打开链接: {}'.format(object_)) + self.click(xpath=object_) - self.open_link(url = object_) + if ( + isinstance(action.get("first_row_identifier_xpath"), str) + and action.get("first_row_identifier_xpath") != "" + ): - #点击 - #动作配置项须包含action_type、object_name和object,若first_row_identifier_xpath数据类型为字符其非空字符则先解析再点击,等待条件为第一行唯一标识与点击之前不相同 - case 'click': + # 等待至第一行唯一标识与点击之前不相同 + WebDriverWait( + driver=self.browser, timeout=300, poll_frequency=1 + ).until( + lambda condition: self.browser.find_element( + By.XPATH, action.get("first_row_identifier_xpath") + ).text + != first_row_identifier + ) - logger.info('正在点击: {}'.format(action.get('object_name'))) + # 选择 + case "select": - if isinstance(action.get('first_row_identifier_xpath'), str) and action.get('first_row_identifier_xpath') != '': + logger.info("正在选择: {}".format(action.get("object_name"))) - #解析点击之前第一行唯一标识 - first_row_identifier = self.browser.find_element(By.XPATH, action.get('first_row_identifier_xpath')).text + self.select(xpaths=object_) - self.click(xpath = object_) + # 输入 + case "input": - if isinstance(action.get('first_row_identifier_xpath'), str) and action.get('first_row_identifier_xpath') != '': + # 若对象内容包含“EXECUTE:”则执行函数并将返回作为对象内容 + if "execute:" in action.get("content"): - #等待至第一行唯一标识与点击之前不相同 - WebDriverWait(driver = self.browser, timeout = 300, poll_frequency = 1).until(lambda condition: self.browser.find_element(By.XPATH, action.get('first_row_identifier_xpath')).text != first_row_identifier) + content = eval(action.get("content").split(" ", 1)[1]) - #选择 - case 'select': + else: - logger.info('正在选择: {}'.format(action.get('object_name'))) + content = action.get("content") - self.select(xpaths = object_) + logger.info( + "正在输入: {} {}".format(action.get("object_name"), content) + ) - #输入 - case 'input': + self.input(xpath=object_, content=content) - #若对象内容包含“EXECUTE:”则执行函数并将返回作为对象内容 - if 'execute:' in action.get('content'): + # 等待至条件达成或超时 + case "wait_until": - content = eval(action.get('content').split(' ', 1)[1]) + if action.get("content") == "": - else: + content_ = "空字符" - content = action.get('content') + else: - logger.info('正在输入: {} {}'.format(action.get('object_name'), content)) + content_ = action.get("content") - self.input(xpath = object_, content = content) + match action.get("expected_condition"): - #等待至条件达成或超时 - case 'wait_until': + case "browser_tab_title_is": - if action.get('content') == '': + logger.info("正在等待 标签页标题 为 {}".format(content_)) - content_ = '空字符' + # 等待至标签页标题为指定内容 + self.wait.until( + lambda condition: self.browser.title == content_ + ) - else: + time.sleep(1) - content_ = action.get('content') + case "element_text_is": - match action.get('expected_condition'): + logger.info( + "正在等待 {} 为 {}".format( + action.get("object_name"), content_ + ) + ) - case 'browser_tab_title_is': + # 等待至定位元素为指定内容 + self.wait.until( + lambda condition: self.browser.find_element( + By.XPATH, object_ + ).text + == content_ + ) - logger.info('正在等待 标签页标题 为 {}'.format(content_)) + time.sleep(1) - #等待至标签页标题为指定内容 - self.wait.until(lambda condition: self.browser.title == content_) + case "element_text_is_not": - time.sleep(1) + logger.info( + "正在等待 {} 不为 {}".format( + action.get("object_name"), content_ + ) + ) - case 'element_text_is': + self.wait.until( + lambda condition: self.browser.find_element( + By.XPATH, object_ + ).text + != content_ + ) - logger.info('正在等待 {} 为 {}'.format(action.get('object_name'), content_)) + time.sleep(1) - #等待至定位元素为指定内容 - self.wait.until(lambda condition: self.browser.find_element(By.XPATH, object_).text == content_) + case "element_text_is_loaded": - time.sleep(1) + logger.info( + "正在等待 {} 加载完成".format(action.get("object_name")) + ) - case 'element_text_is_not': + self.wait.until( + presence_of_element_located((By.XPATH, object_)) + ) - logger.info('正在等待 {} 不为 {}'.format(action.get('object_name'), content_)) + time.sleep(1) - self.wait.until(lambda condition: self.browser.find_element(By.XPATH, object_).text != content_) + case "table_rows_is_not_zero": - time.sleep(1) + logger.info( + "正在等待 {} 行数不为0".format( + action.get("object_name") + ) + ) - case 'element_text_is_loaded': + # 等待至表格行数不为0 + WebDriverWait( + driver=self.browser, timeout=300, poll_frequency=1 + ).until( + lambda condition: self.browser.find_element( + By.XPATH, object_ + ).get_attribute("childElementCount") + != "0" + ) - logger.info('正在等待 {} 加载完成'.format(action.get('object_name'))) + time.sleep(1) - self.wait.until(presence_of_element_located((By.XPATH, object_))) + # 若未匹配则返假 + case default: - time.sleep(1) + raise Exception("等待条件未定义") - case 'table_rows_is_not_zero': + # 认知(需要补充) + case "cognize": - logger.info('正在等待 {} 行数不为0'.format(action.get('object_name'))) + match action.get("cognized_condition"): - #等待至表格行数不为0 - WebDriverWait(driver = self.browser, timeout = 300, poll_frequency = 1).until(lambda condition: self.browser.find_element(By.XPATH, object_).get_attribute('childElementCount') != '0') + case "text_is": - time.sleep(1) - - #若未匹配则返假 - case default: + logger.info( + "正在判断 {} 是否为 {}".format( + action.get("object_name"), action.get("content") + ) + ) - raise Exception('等待条件未定义') + # 若定位元素非指定内容则终止后续动作 + if self.browser.find_element( + By.XPATH, object_ + ).text == action.get("content"): - #认知(需要补充) - case 'cognize': + match action.get("meet"): - match action.get('cognized_condition'): + case "pass": - case 'text_is': + logger.info("是,跳过") - logger.info('正在判断 {} 是否为 {}'.format(action.get('object_name'), action.get('content'))) + pass - #若定位元素非指定内容则终止后续动作 - if self.browser.find_element(By.XPATH, object_).text == action.get('content'): + case "break": - match action.get('meet'): + logger.info("是,终止执行后续动作") - case 'pass': + break - logger.info('是,跳过') + case default: - pass + raise Exception("预期结果为是时动作未定义") - case 'break': + else: - logger.info('是,终止执行后续动作') + match action.get("otherwies"): - break + case "pass": - case default: + logger.info("否,跳过") - raise Exception('预期结果为是时动作未定义') + pass - else: + case "break": - match action.get('otherwies'): + logger.info("否,终止执行后续动作") - case 'pass': + break - logger.info('否,跳过') + case default: - pass + raise Exception("预期结果为不是时动作未定义") - case 'break': + # 普康健康自动化审核 + # 后续考虑配置项化 + case "auto_audit": - logger.info('否,终止执行后续动作') + # 获取保险总公司 + insurance = action.get("insurance") - break + self.close_stipulation(action=action) - case default: + logger.info("正在判断 该赔案是否可自动审核") - raise Exception('预期结果为不是时动作未定义') + try: - #普康健康自动化审核 - #后续考虑配置项化 - case 'auto_audit': + # 实例化普康健康认知模型,获取理算前认知模型 + cognition = Cognition( + extractions=self.data + ).before_adjustment(insurance=insurance) - #获取保险总公司 - insurance = action.get('insurance') + # 获取赔付结论原因(用于赔付时在结论原因中备注票据拒付等信息) + payment_remark = cognition.get("赔付结论原因") - self.close_stipulation(action = action) + assert cognition.get("自动审核") - logger.info('正在判断 该赔案是否可自动审核') + except: - try: + logger.info("该赔案不可自动审核,转人工审核") - #实例化普康健康认知模型,获取理算前认知模型 - cognition = Cognition(extractions = self.data).before_adjustment(insurance = insurance) + self.close_and_switch() - #获取赔付结论原因(用于赔付时在结论原因中备注票据拒付等信息) - payment_remark = cognition.get('赔付结论原因') + return "failure" - assert cognition.get('自动审核') + logger.info("该赔案可自动审核,继续") - except: + logger.info("正在判断 该赔案是否需要拒付票据") - logger.info('该赔案不可自动审核,转人工审核') + try: - self.close_and_switch() + # 拒付票据,考虑在拒付时可能需要将合理金额不为0的票据拒付,故抽象若需要拒付票据再根据认知处理 + assert cognition.get("票据拒付") - return 'failure' + logger.info("该赔案需要拒付票据") - logger.info('该赔案可自动审核,继续') + try: - logger.info('正在判断 该赔案是否需要拒付票据') + self.invoices_refuse( + action=action, + insurance=insurance, + cognition=cognition, + ) - try: + except: - #拒付票据,考虑在拒付时可能需要将合理金额不为0的票据拒付,故抽象若需要拒付票据再根据认知处理 - assert cognition.get('票据拒付') + logger.info("拒付票据发生异常,跳过该赔案") - logger.info('该赔案需要拒付票据') + self.close_and_switch() - try: + return "failure" - self.invoices_refuse(action = action, insurance = insurance, cognition = cognition) + except: - except: + logger.info("该赔案无需拒付票据,继续") - logger.info('拒付票据发生异常,跳过该赔案') + logger.info("正在判断 该赔案理算保单") - self.close_and_switch() + try: - return 'failure' + # 获取所选保单索引 + slip_index = cognition.get("所选保单索引") - except: + self.slip_select(action=action, slip_index=slip_index) - logger.info('该赔案无需拒付票据,继续') + except: - logger.info('正在判断 该赔案理算保单') + logger.info("判断理算保单发生异常,跳过该赔案") - try: + self.close_and_switch() - #获取所选保单索引 - slip_index = cognition.get('所选保单索引') + return "failure" - self.slip_select(action = action, slip_index = slip_index) + try: - except: + self.case_report( + action=action, + insurance=insurance, + cognition=cognition, + ) - logger.info('判断理算保单发生异常,跳过该赔案') + # 在线报案发生异常则跳过 + except: - self.close_and_switch() + logger.info("在线报案发生异常,跳过该赔案") - return 'failure' + self.close_and_switch() - try: + return "failure" - self.case_report(action = action, insurance = insurance, cognition = cognition) + # 就中银保天津分公司若票据就诊类型:包含药房购药和门急诊就诊则取消门急诊就诊关联药房购药责任 + if ( + cognition.get("所选保单所属保险分公司") + == "中银保险有限公司天津分公司" + ): - #在线报案发生异常则跳过 - except: + # 就诊类型包括药店购药和门急诊就诊 + if "药店购药" in [ + invoice.get("就诊类型") + for invoice in cognition.get("转换数据").get( + "票据信息" + ) + ] and "门急诊就诊" in [ + invoice.get("就诊类型") + for invoice in cognition.get("转换数据").get( + "票据信息" + ) + ]: - logger.info('在线报案发生异常,跳过该赔案') + # 等待至票据表格所有元素加载完成 + element = self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get("table_invoices_xpath"), + ) + ) + ) - self.close_and_switch() + # 解析票据行数 + indices = int( + element.get_attribute("childElementCount") + ) + + # 遍历票据表格索引 + for index in range(indices): + + index += 1 + + # 等待至索引行元素加载完成 + element = self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get( + "field_invoice_identifier_xpath" + ) + .replace( + "tr[index]", + "tr[{}]".format(index), + ) + .rsplit("/", 1)[0], + ) + ) + ) + + # 将索引行移动至可见 + self.browser.execute_script( + "arguments[0].scrollIntoView(true);", + element, + ) + + # 点击索引行的行唯一标识 + self.click( + xpath=action.get( + "field_invoice_identifier_xpath" + ).replace("[index]", "[{}]".format(index)) + ) + + element = self.wait.until( + presence_of_element_located( + ( + By.XPATH, + '//*[@id="pane-first"]/div/div[3]/table/tbody/tr[index]/td[7]'.replace( + "tr[index]", + "tr[{}]".format(index), + ), + ) + ) + ) + + # 若该张票据就诊类型为真急诊就诊 + if element.text == "门/急诊": + + time.sleep(1) + + self.click( + xpath='//*[@id="pane-first"]/div/div[3]/table/tbody/tr[index]/td[9]/div/div/div/div[1]/input'.replace( + "tr[index]", "tr[{}]".format(index) + ) + ) + + time.sleep(1) + + self.click( + xpath="(/html/body/div/div[1]/div[1]/ul/li[2])[last()]" + ) + + time.sleep(1) + + # 就诊类型均为门急诊就诊 + if all( + [ + invoice.get("就诊类型") == "门急诊就诊" + for invoice in cognition.get("转换数据").get( + "票据信息" + ) + ] + ): + + # 等待至票据表格所有元素加载完成 + element = self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get("table_invoices_xpath"), + ) + ) + ) + + # 解析票据行数 + indices = int( + element.get_attribute("childElementCount") + ) + + # 遍历票据表格索引 + for index in range(indices): + + index += 1 + + # 等待至索引行元素加载完成 + element = self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get( + "field_invoice_identifier_xpath" + ) + .replace( + "tr[index]", + "tr[{}]".format(index), + ) + .rsplit("/", 1)[0], + ) + ) + ) + + # 将索引行移动至可见 + self.browser.execute_script( + "arguments[0].scrollIntoView(true);", + element, + ) + + # 点击索引行的行唯一标识 + self.click( + xpath=action.get( + "field_invoice_identifier_xpath" + ).replace("[index]", "[{}]".format(index)) + ) + + element = self.wait.until( + presence_of_element_located( + ( + By.XPATH, + '//*[@id="pane-first"]/div/div[3]/table/tbody/tr[index]/td[7]'.replace( + "tr[index]", + "tr[{}]".format(index), + ), + ) + ) + ) + + # 若该张票据就诊类型为真急诊就诊 + if element.text == "门/急诊": - return 'failure' + time.sleep(1) - #就中银保天津分公司若票据就诊类型:包含药房购药和门急诊就诊则取消门急诊就诊关联药房购药责任 - if cognition.get('所选保单所属保险分公司') == '中银保险有限公司天津分公司': + # 先选择 + self.click( + xpath='//*[@id="pane-first"]/div/div[3]/table/tbody/tr[index]/td[9]/div/div/div/div[1]/input'.replace( + "tr[index]", "tr[{}]".format(index) + ) + ) - #就诊类型包括药店购药和门急诊就诊 - if '药店购药' in [invoice.get('就诊类型') for invoice in cognition.get('转换数据').get('票据信息')] and '门急诊就诊' in [invoice.get('就诊类型') for invoice in cognition.get('转换数据').get('票据信息')]: + time.sleep(1) - #等待至票据表格所有元素加载完成 - element = self.wait.until(presence_of_element_located((By.XPATH, action.get('table_invoices_xpath')))) + # 再选择相应责任 + self.click( + xpath="(/html/body/div/div[1]/div[1]/ul/li[1])[last()]" + ) - #解析票据行数 - indices = int(element.get_attribute('childElementCount')) + time.sleep(1) - #遍历票据表格索引 - for index in range(indices): + self.click( + xpath='//*[@id="app"]/div/div/section/main/section[1]/div[5]/div[3]/button[7]' + ) - index += 1 + time.sleep(1) - #等待至索引行元素加载完成 - element = self.wait.until(presence_of_element_located((By.XPATH, action.get('field_invoice_identifier_xpath').replace('tr[index]', 'tr[{}]'.format(index)).rsplit('/', 1)[0]))) + try: - #将索引行移动至可见 - self.browser.execute_script("arguments[0].scrollIntoView(true);", element) + assert self.adjust(action=action) is True - #点击索引行的行唯一标识 - self.click(xpath = action.get('field_invoice_identifier_xpath').replace('[index]', '[{}]'.format(index))) + # 理算发生异常则跳过 + except: - element = self.wait.until(presence_of_element_located((By.XPATH, '//*[@id="pane-first"]/div/div[3]/table/tbody/tr[index]/td[7]'.replace('tr[index]', 'tr[{}]'.format(index))))) + logger.info("理算发生异常,跳过该赔案") - #若该张票据就诊类型为真急诊就诊 - if element.text == '门/急诊': + self.close_and_switch() - time.sleep(1) + return "failure" - self.click(xpath = '//*[@id="pane-first"]/div/div[3]/table/tbody/tr[index]/td[9]/div/div/div/div[1]/input'.replace('tr[index]', 'tr[{}]'.format(index))) + logger.info("正在抽取数据") - time.sleep(1) + time.sleep(3) - self.click(xpath = '(/html/body/div/div[1]/div[1]/ul/li[2])[last()]') + # 仅保留影像件抽取内容 + self.data = [ + {key: value} + for extraction in self.data + for key, value in extraction.items() + if "影像件" in key + ] - time.sleep(1) + self.extract(extractions=action.get("extractions")) - #就诊类型均为门急诊就诊 - if all([invoice.get('就诊类型') == '门急诊就诊' for invoice in cognition.get('转换数据').get('票据信息')]): + # 实例化普康健康认知模型,获取理算后相应认知 + cognition = Cognition( + extractions=self.data + ).after_adjustment(insurance=insurance) - #等待至票据表格所有元素加载完成 - element = self.wait.until(presence_of_element_located((By.XPATH, action.get('table_invoices_xpath')))) + logger.info("正在判断 该赔案是否赔付") - #解析票据行数 - indices = int(element.get_attribute('childElementCount')) + # 将页面滑动至底部 + self.browser.execute_script( + "window.scrollTo(0, document.body.scrollHeight);" + ) - #遍历票据表格索引 - for index in range(indices): + time.sleep(1) - index += 1 + print(cognition) - #等待至索引行元素加载完成 - element = self.wait.until(presence_of_element_located((By.XPATH, action.get('field_invoice_identifier_xpath').replace('tr[index]', 'tr[{}]'.format(index)).rsplit('/', 1)[0]))) + # 根据决策结果赔付 + if cognition.get("自动化:审核结论") == 1: - #将索引行移动至可见 - self.browser.execute_script("arguments[0].scrollIntoView(true);", element) + logger.info("该赔案应该赔付") - #点击索引行的行唯一标识 - self.click(xpath = action.get('field_invoice_identifier_xpath').replace('[index]', '[{}]'.format(index))) + logger.info("正在选择: 理赔结论-赔付") - element = self.wait.until(presence_of_element_located((By.XPATH, '//*[@id="pane-first"]/div/div[3]/table/tbody/tr[index]/td[7]'.replace('tr[index]', 'tr[{}]'.format(index))))) + self.select(xpaths=action.get("droplist_pay_xpaths")) - #若该张票据就诊类型为真急诊就诊 - if element.text == '门/急诊': + logger.info("正在输入: 结论原因") - time.sleep(1) + # 若足额赔付为否,需要调整赔付是结论原因 + if not cognition.get("足额赔付"): - #先选择 - self.click(xpath = '//*[@id="pane-first"]/div/div[3]/table/tbody/tr[index]/td[9]/div/div/div/div[1]/input'.replace('tr[index]', 'tr[{}]'.format(index))) + payment_remark = ( + payment_remark + + "\n累计赔付已达到个人账户年度限额" + ) - time.sleep(1) + self.input( + xpath=action.get("textarea_refuse_remark_xpath"), + content=payment_remark, + ) - #再选择相应责任 - self.click(xpath = '(/html/body/div/div[1]/div[1]/ul/li[1])[last()]') + else: - time.sleep(1) + logger.info("该赔案应该拒付") - self.click(xpath = '//*[@id="app"]/div/div/section/main/section[1]/div[5]/div[3]/button[7]') + logger.info( + "拒付合理金额、部分自费金额和全部自费金额之和不为0的票据" + ) - time.sleep(1) + try: - try: + self.invoices_refuse( + action=action, insurance=insurance + ) - assert self.adjust(action = action) is True + except Exception as e: - #理算发生异常则跳过 - except: + print(e) - logger.info('理算发生异常,跳过该赔案') + logger.info("拒付票据发生异常,跳过该赔案") - self.close_and_switch() + self.close_and_switch() - return 'failure' + return "failure" - logger.info('正在抽取数据') + # 刷新页面,取消已选保单 + self.browser.refresh() - time.sleep(3) + # 等待至赔案号加载完成 + self.wait.until( + lambda condition: presence_of_element_located( + ( + By.XPATH, + action.get("field_case_number_xpath"), + ) + ) + ) - #仅保留影像件抽取内容 - self.data = [{key: value} for extraction in self.data for key, value in extraction.items() if '影像件' in key] + try: - self.extract(extractions = action.get('extractions')) + self.slip_select( + action=action, slip_index=slip_index + ) - #实例化普康健康认知模型,获取理算后相应认知 - cognition = Cognition(extractions = self.data).after_adjustment(insurance = insurance) + # 选择保单发生异常则跳过 + except: - logger.info('正在判断 该赔案是否赔付') + logger.info("选择保单发生异常,跳过该赔案") - #将页面滑动至底部 - self.browser.execute_script('window.scrollTo(0, document.body.scrollHeight);') + self.close_and_switch() - time.sleep(1) + return "failure" - print(cognition) + try: - #根据决策结果赔付 - if cognition.get('自动化:审核结论') == 1: + assert self.adjust(action=action) is True - logger.info('该赔案应该赔付') + # 理算发生异常则跳过 + except: - logger.info('正在选择: 理赔结论-赔付') + logger.info("理算发生异常,跳过该赔案") - self.select(xpaths = action.get('droplist_pay_xpaths')) + self.close_and_switch() - logger.info('正在输入: 结论原因') + return "failure" - #若足额赔付为否,需要调整赔付是结论原因 - if not cognition.get('足额赔付'): + logger.info("正在选择: 理赔结论-拒付") - payment_remark = payment_remark + '\n累计赔付已达到个人账户年度限额' + self.select(xpaths=action.get("droplist_refuse_xpaths")) - self.input(xpath = action.get('textarea_refuse_remark_xpath'), content = payment_remark) + logger.info("正在输入: 结论原因") - else: + self.input( + xpath=action.get("textarea_refuse_remark_xpath"), + content=cognition.get("自动化:审核说明"), + ) - logger.info('该赔案应该拒付') + logger.info("正在点击: 通过按钮") - logger.info('拒付合理金额、部分自费金额和全部自费金额之和不为0的票据') + self.click(xpath=action.get("button_audit_xpath")) - try: + logger.info("正在判断 模态弹窗标题 是否包含 发票日期超保期") - self.invoices_refuse(action = action, insurance = insurance) + try: - except Exception as e: + # 等待至模态弹窗标题包含发票日期超保期 + WebDriverWait( + driver=self.browser, timeout=3, poll_frequency=1 + ).until( + lambda condition: "发票日期超保期" + in self.browser.find_element( + By.XPATH, + action.get("text_without_assurance_xpath"), + ).text + ) - print(e) + logger.info("是,关闭模态弹窗") - logger.info('拒付票据发生异常,跳过该赔案') + self.click( + xpath=action.get( + "button_confrim_without_assurance_xpath" + ) + ) - self.close_and_switch() + except: - return 'failure' + logger.info("否,继续") - #刷新页面,取消已选保单 - self.browser.refresh() + self.close_dialog() - #等待至赔案号加载完成 - self.wait.until(lambda condition: presence_of_element_located((By.XPATH, action.get('field_case_number_xpath')))) + logger.info("正在判断 模态弹窗标题 是否包含 提示") - try: + try: - self.slip_select(action = action, slip_index = slip_index) + # 等待至模态弹窗标题包含提示 + WebDriverWait( + driver=self.browser, timeout=3, poll_frequency=1 + ).until( + lambda condition: "提示" + in self.browser.find_element( + By.XPATH, action.get("text_prompt_xpath") + ).text + ) - #选择保单发生异常则跳过 - except: + logger.info("是,关闭模态弹窗") - logger.info('选择保单发生异常,跳过该赔案') + self.click( + xpath=action.get("button_close_prompt_xpath") + ) - self.close_and_switch() + except: - return 'failure' + logger.info("否,继续") - try: + logger.info("正在判断 模态弹窗标题 是否包含 发票关联影像") - assert self.adjust(action = action) is True + try: - #理算发生异常则跳过 - except: + # 等待至提示审核成功 + WebDriverWait( + driver=self.browser, timeout=3, poll_frequency=1 + ).until( + lambda condition: "发票关联影像" + in self.browser.find_element( + By.XPATH, + action.get("text_prompt_invoices_xpath"), + ).text + ) - logger.info('理算发生异常,跳过该赔案') + logger.info("是,跳过该赔案") - self.close_and_switch() + self.close_and_switch() - return 'failure' + return "failure" - logger.info('正在选择: 理赔结论-拒付') + except: - self.select(xpaths = action.get('droplist_refuse_xpaths')) + logger.info("审核成功") - logger.info('正在输入: 结论原因') + case default: - self.input(xpath = action.get('textarea_refuse_remark_xpath'), content = cognition.get('自动化:审核说明')) + raise Exception("判断条件未定义") - logger.info('正在点击: 通过按钮') + # 关闭模态弹窗 + case "close_dialog": - self.click(xpath = action.get('button_audit_xpath')) + logger.info("正在关闭模态弹窗") - logger.info('正在判断 模态弹窗标题 是否包含 发票日期超保期') + # 默认在模态弹窗中点击无动作预期 + self.close_dialog() - try: + # 点击并切换至新标签页 + case "click_and_switch": - #等待至模态弹窗标题包含发票日期超保期 - WebDriverWait(driver = self.browser, timeout = 3, poll_frequency = 1).until(lambda condition: '发票日期超保期' in self.browser.find_element(By.XPATH, action.get('text_without_assurance_xpath')).text) + logger.info( + "正在点击 {} 并切换至 {} 标签页".format( + action.get("object_name")[0], action.get("object_name")[1] + ) + ) - logger.info('是,关闭模态弹窗') + self.click_and_switch(xpath=object_) - self.click(xpath = action.get('button_confrim_without_assurance_xpath')) + # 关闭当前标签页并切换至上一标签页 + case "close_and_switch": - except: + logger.info( + "正在关闭 {} 标签页并切换至 {} 标签页".format( + action.get("object_name")[0], action.get("object_name")[1] + ) + ) - logger.info('否,继续') + self.close_and_switch() - self.close_dialog() + # 抽取数据 + case "extract": - logger.info('正在判断 模态弹窗标题 是否包含 提示') + logger.info("正在抽取数据") - try: + time.sleep(3) - #等待至模态弹窗标题包含提示 - WebDriverWait(driver = self.browser, timeout = 3, poll_frequency = 1).until(lambda condition: '提示' in self.browser.find_element(By.XPATH, action.get('text_prompt_xpath')).text) + self.extract(extractions=action.get("extractions")) - logger.info('是,关闭模态弹窗') + # 重做 + # 动作组配置项包含:row_identifier_xpath 行唯一标识 + # 若行唯一标识包含索引则替换标识: + # 适用场景: + # 若动作组配置项包含下一分页按钮XPATH,则依次遍历直至达到预期重复数 + # 若动作组配置项不包含下一分页按钮XPATH,则重做 + case "repeat": - self.click(xpath = action.get('button_close_prompt_xpath')) + logger.info("重做: {}".format(action.get("object_name"))) - except: + # 若行唯一标识包含[index]则使用实际索引替换索引标识(index)至达到预期完成任务数或下一分页不可点击 + if "[index]" in action.get("row_identifier_xpath"): - logger.info('否,继续') + while True: - logger.info('正在判断 模态弹窗标题 是否包含 发票关联影像') + # 解析当前分页表格行数 + indices = int( + self.browser.find_element( + By.XPATH, action.get("table_xpath") + ).get_attribute("childElementCount") + ) - try: + # 若当前分页表格行数为0则结束重复 + if indices == 0: - #等待至提示审核成功 - WebDriverWait(driver = self.browser, timeout = 3, poll_frequency = 1).until(lambda condition: '发票关联影像' in self.browser.find_element(By.XPATH, action.get('text_prompt_invoices_xpath')).text) + logger.info("表格行数为0,结束重复") - logger.info('是,跳过该赔案') + break - self.close_and_switch() + # 遍历当前分页表格行 + for index in range(1, indices + 1): - return 'failure' + # 解析行唯一标识 + row_identifier = self.browser.find_element( + By.XPATH, + action.get("row_identifier_xpath").replace( + "[index]", "[{}]".format(index) + ), + ).text - except: + if row_identifier != "": - logger.info('审核成功') + logger.info("就 {} 执行任务".format(row_identifier)) - case default: + else: - raise Exception('判断条件未定义') + logger.info("就第 {} 个执行任务".format(index)) - #关闭模态弹窗 - case 'close_dialog': + # 点击行唯一标识(用于将该行可见) + self.click( + xpath=action.get("row_identifier_xpath").replace( + "[index]", "[{}]".format(index) + ) + ) - logger.info('正在关闭模态弹窗') + # 若执行动作时发生异常则跳过 + try: - #默认在模态弹窗中点击无动作预期 - self.close_dialog() + assert ( + self.translator( + actions=action.get("actions"), index=index + ) + == "success" + ) - #点击并切换至新标签页 - case 'click_and_switch': + logger.info("执行成功,继续下一任务") - logger.info('正在点击 {} 并切换至 {} 标签页'.format(action.get('object_name')[0], action.get('object_name')[1])) + except: - self.click_and_switch(xpath = object_) + # 尝试关闭除第一个标签页以外的标签页,否则抛出异常 + try: - #关闭当前标签页并切换至上一标签页 - case 'close_and_switch': + logger.info( + "执行任务时发生异常,跳过并继续下一任务" + ) - logger.info('正在关闭 {} 标签页并切换至 {} 标签页'.format(action.get('object_name')[0], action.get('object_name')[1])) + self.close_and_switch_to_first() - self.close_and_switch() + continue - #抽取数据 - case 'extract': + except Exception as exception: - logger.info('正在抽取数据') + raise exception - time.sleep(3) + # 若动作组配置项重下一分页按钮XPATH数据类型为字符且非空字符则判断是否达到预期完成任务数,若达到则终止并跳出循环 + if ( + isinstance(action.get("button_next_xpath"), str) + and action.get("button_next_xpath") != "" + ): - self.extract(extractions = action.get('extractions')) + if self.tasks >= action.get("expected_tasks"): - #重做 - #动作组配置项包含:row_identifier_xpath 行唯一标识 - #若行唯一标识包含索引则替换标识: - #适用场景: - #若动作组配置项包含下一分页按钮XPATH,则依次遍历直至达到预期重复数 - #若动作组配置项不包含下一分页按钮XPATH,则重做 - case 'repeat': + break - logger.info('重做: {}'.format(action.get('object_name'))) + # 若动作组配置项重下一分页按钮XPATH数据类型为字符且非空字符则判断是否达到预期完成任务数或者下一分页按钮不可点击 + if ( + isinstance(action.get("button_next_xpath"), str) + and action.get("button_next_xpath") != "" + ): - #若行唯一标识包含[index]则使用实际索引替换索引标识(index)至达到预期完成任务数或下一分页不可点击 - if '[index]' in action.get('row_identifier_xpath'): + if self.tasks >= action.get("expected_tasks"): - while True: + logger.info("达到预期完成任务数,结束重复") - #解析当前分页表格行数 - indices = int(self.browser.find_element(By.XPATH, action.get('table_xpath')).get_attribute('childElementCount')) + break - #若当前分页表格行数为0则结束重复 - if indices == 0: + if self.wait.until( + presence_of_element_located( + (By.XPATH, action.get("button_next_xpath")) + ) + ).get_attribute("disabled"): - logger.info('表格行数为0,结束重复') + logger.info("下一分页按钮不可点击,结束重复") - break + break - #遍历当前分页表格行 - for index in range(1, indices + 1): + logger.info("正在点击: 下一分页按钮") - #解析行唯一标识 - row_identifier = self.browser.find_element(By.XPATH, action.get('row_identifier_xpath').replace('[index]', '[{}]'.format(index))).text + # 解析点击之前第一行唯一标识 + first_row_identifier = self.browser.find_element( + By.XPATH, action.get("first_row_identifier_xpath") + ).text - if row_identifier != '': + self.click(xpath=action.get("button_next_xpath")) - logger.info('就 {} 执行任务'.format(row_identifier)) + # 等待至第一行唯一标识与点击之前不相同 + WebDriverWait( + driver=self.browser, timeout=300, poll_frequency=1 + ).until( + lambda condition: self.browser.find_element( + By.XPATH, + action.get("first_row_identifier_xpath"), + ).text + != first_row_identifier + ) - else: + # 若不满足下一分页按钮XPATH数据类型为字符且非空字符则退出循环(此情况不支持翻页) + else: - logger.info('就第 {} 个执行任务'.format(index)) + break - #点击行唯一标识(用于将该行可见) - self.click(xpath = action.get('row_identifier_xpath').replace('[index]', '[{}]'.format(index))) + else: - #若执行动作时发生异常则跳过 - try: + # 预期行索引(用于初始化行索引) + index = action.get("expected_index") - assert self.translator(actions = action.get('actions'), index = index) == 'success' + while True: - logger.info('执行成功,继续下一任务') + if index > 20: - except: + self.select(xpaths=action.get("droplist_more_xpaths")) - #尝试关闭除第一个标签页以外的标签页,否则抛出异常 - try: + try: - logger.info('执行任务时发生异常,跳过并继续下一任务') + # 将索引行移动至可见 + self.browser.execute_script( + "arguments[0].scrollIntoView(true);", + self.wait.until( + presence_of_element_located( + ( + By.XPATH, + action.get("row_xpath").replace( + "tr[index]", "tr[{}]".format(index) + ), + ) + ) + ), + ) - self.close_and_switch_to_first() + except: - continue + logger.info("行唯一标识加载发生异常,停止重复执行动作组") - except Exception as exception: + break - raise exception + # 点击行唯一标识(用于将行可见) + self.click( + xpath=action.get("row_identifier_xpath").replace( + "tr[1]", "tr[{}]".format(index) + ) + ) - #若动作组配置项重下一分页按钮XPATH数据类型为字符且非空字符则判断是否达到预期完成任务数,若达到则终止并跳出循环 - if isinstance(action.get('button_next_xpath'), str) and action.get('button_next_xpath') != '': + # 解析行唯一标识 + row_identifier = self.browser.find_element( + By.XPATH, + action.get("row_identifier_xpath").replace( + "tr[1]", "tr[{}]".format(index) + ), + ).text - if self.tasks >= action.get('expected_tasks'): + logger.info("就 {} 执行任务".format(row_identifier)) - break + # 若成功执行则刷新并继续以当前行索引执行重复动作组,若无法执行则以下一个行索引执行重复动作组,若发生异常则重新执行重复动作组 + try: - #若动作组配置项重下一分页按钮XPATH数据类型为字符且非空字符则判断是否达到预期完成任务数或者下一分页按钮不可点击 - if isinstance(action.get('button_next_xpath'), str) and action.get('button_next_xpath') != '': + if ( + self.translator( + actions=action.get("actions"), index=index + ) + == "success" + ): - if self.tasks >= action.get('expected_tasks'): + while True: - logger.info('达到预期完成任务数,结束重复') + # 刷新页面,等待至行唯一标识与刷新前不一致 + try: - break + self.browser.refresh() - if self.wait.until(presence_of_element_located((By.XPATH, action.get('button_next_xpath')))).get_attribute('disabled'): + self.select( + xpaths=action.get( + "droplist_more_xpaths" + ) + ) - logger.info('下一分页按钮不可点击,结束重复') + self.wait.until( + lambda condition: self.browser.find_element( + By.XPATH, + action.get( + "row_identifier_xpath" + ).replace( + "tr[1]", "tr[{}]".format(index) + ), + ).text + != row_identifier + ) - break + logger.info("执行成功") - logger.info('正在点击: 下一分页按钮') + break - #解析点击之前第一行唯一标识 - first_row_identifier = self.browser.find_element(By.XPATH, action.get('first_row_identifier_xpath')).text + except: - self.click(xpath = action.get('button_next_xpath')) + time.sleep(1) - #等待至第一行唯一标识与点击之前不相同 - WebDriverWait(driver = self.browser, timeout = 300, poll_frequency = 1).until(lambda condition: self.browser.find_element(By.XPATH, action.get('first_row_identifier_xpath')).text != first_row_identifier) + else: - #若不满足下一分页按钮XPATH数据类型为字符且非空字符则退出循环(此情况不支持翻页) - else: + index += 1 - break + logger.info( + "执行动作组失败,以下一个行索引执行动作组" + ) - else: + except: - #预期行索引(用于初始化行索引) - index = action.get('expected_index') + try: - while True: + self.close_dialog() - if index > 20: + self.close_and_switch_to_first() - self.select(xpaths = action.get('droplist_more_xpaths')) + logger.info("执行动作组发生异常,重新执行该动作组") - try: + continue - #将索引行移动至可见 - self.browser.execute_script("arguments[0].scrollIntoView(true);", self.wait.until(presence_of_element_located((By.XPATH, action.get('row_xpath').replace('tr[index]', 'tr[{}]'.format(index)))))) + except Exception as exception: - except: + raise exception - logger.info('行唯一标识加载发生异常,停止重复执行动作组') + # 若重复数大于等于预期重复数则终止 + if self.tasks >= action.get("expected_tasks"): - break + logger.info("达到预期重复数") - #点击行唯一标识(用于将行可见) - self.click(xpath = action.get('row_identifier_xpath').replace('tr[1]', 'tr[{}]'.format(index))) - - #解析行唯一标识 - row_identifier = self.browser.find_element(By.XPATH, action.get('row_identifier_xpath').replace('tr[1]', 'tr[{}]'.format(index))).text + break - logger.info('就 {} 执行任务'.format(row_identifier)) - - #若成功执行则刷新并继续以当前行索引执行重复动作组,若无法执行则以下一个行索引执行重复动作组,若发生异常则重新执行重复动作组 - try: + # 重复动作结束 + case "repeat_finish": - if self.translator(actions = action.get('actions'), index = index) == 'success': + # 将抽取内容保存为本地文件 + with open( + "data/{}.json".format( + datetime.now().strftime("%y-%m-%d %H-%M-%S") + ), + "w", + encoding="utf-8", + ) as file: - while True: + json.dump(self.data, file, ensure_ascii=False) - #刷新页面,等待至行唯一标识与刷新前不一致 - try: + # 将抽取内容添加至数据集 + self.dataset.append(self.data) - self.browser.refresh() + # 重置抽取数据 + self.data = [] - self.select(xpaths = action.get('droplist_more_xpaths')) + # 重复数自增 + self.tasks += 1 - self.wait.until(lambda condition: self.browser.find_element(By.XPATH, action.get('row_identifier_xpath').replace('tr[1]', 'tr[{}]'.format(index))).text != row_identifier) + # 结束 + case "finish": - logger.info('执行成功') + # 将所有抽取内容保存为本地文件 + with open( + "dataset/{}.json".format( + datetime.now().strftime("%y-%m-%d %H-%M-%S") + ), + "w", + encoding="utf-8", + ) as file: - break + json.dump(self.dataset, file, ensure_ascii=False) - except: + # 若未匹配则返假 + case default: - time.sleep(1) + raise Exception("动作类型未定义") - else: + return "success" - index += 1 - logger.info('执行动作组失败,以下一个行索引执行动作组') - - except: - - try: - - self.close_dialog() - - self.close_and_switch_to_first() - - logger.info('执行动作组发生异常,重新执行该动作组') - - continue - - except Exception as exception: - - raise exception - - #若重复数大于等于预期重复数则终止 - if self.tasks >= action.get('expected_tasks'): - - logger.info('达到预期重复数') - - break - - #重复动作结束 - case 'repeat_finish': - - #将抽取内容保存为本地文件 - with open('data/{}.json'.format(datetime.now().strftime('%y-%m-%d %H-%M-%S')), 'w', encoding= 'utf-8') as file: - - json.dump(self.data, file, ensure_ascii = False) - - #将抽取内容添加至数据集 - self.dataset.append(self.data) - - #重置抽取数据 - self.data = [] - - #重复数自增 - self.tasks += 1 - - #结束 - case 'finish': - - #将所有抽取内容保存为本地文件 - with open('dataset/{}.json'.format(datetime.now().strftime('%y-%m-%d %H-%M-%S')), 'w', encoding= 'utf-8') as file: - - json.dump(self.dataset, file, ensure_ascii = False) - - #若未匹配则返假 - case default: - - raise Exception('动作类型未定义') - - return 'success' - -''' +""" #等待至票据表格所有元素加载完成 element = self.wait.until(presence_of_element_located((By.XPATH, action.get('table_xpath')))) @@ -1382,4 +1930,4 @@ class PageObject: #若行索引大于当前分页票据表格行数则点击更多按钮 if index > indices: -''' +""" diff --git a/票据理赔自动化/database.db b/票据理赔自动化/database.db index e2c0ee7..dff7ab8 100644 Binary files a/票据理赔自动化/database.db and b/票据理赔自动化/database.db differ diff --git a/票据理赔自动化/main.py b/票据理赔自动化/main.py index 8421ec6..5c0894c 100644 --- a/票据理赔自动化/main.py +++ b/票据理赔自动化/main.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ -基于普康票据理赔自动化最小化实现 +票据理赔自动化最小化实现 功能清单 https://liubiren.feishu.cn/docx/WFjTdBpzroUjQvxxrNIcKvGnneh?from=from_copylink """ @@ -20,43 +20,26 @@ import pandas from fuzzywuzzy import fuzz from jinja2 import Environment, FileSystemLoader from jionlp import parse_location -from zen import ZenDecision, ZenEngine from utils.client import Authenticator, HTTPClient, SQLiteClient +from utils.rule_engine import RuleEngine # ------------------------- # 主逻辑 # ------------------------- if __name__ == "__main__": - # 实例认证器 + # 实例化认证器 authenticator = Authenticator() - # 实例请求客户端 + # 实例化请求客户端 http_client = HTTPClient(timeout=300, cache_enabled=True) # 使用缓存 - # 初始化工作目录地址对象 - directory_path = Path("directory") - # 若不存在则创建 - directory_path.mkdir(parents=True, exist_ok=True) + # 初始化工作目录路径 + workplace_path = Path("directory") + workplace_path.mkdir(parents=True, exist_ok=True) # 若工作目录不存在则创建 - def rule_engine(rule_path: Path) -> ZenDecision: - """ - 本地打开并读取规则文件并实例化规则引擎 - :param rule_path: 规则文件路径对象 - """ - - def loader(path): - with open(path, "r", encoding="utf-8") as file: - return file.read() - - return ZenEngine({"loader": loader}).get_decision(rule_path.as_posix()) - - # 影像件识别使能 - recognize_enabled = rule_engine(Path("rules/影像件识别使能.json")) - # 药店购药明细项不合理费用扣除 - deduct_unreasonable_amount = rule_engine( - Path("rules/药店购药明细项不合理费用扣除.json") - ) + # 实例化规则引擎 + rule_engine = RuleEngine(rules_path=Path("rules")) class MasterData(SQLiteClient): """主数据""" @@ -67,10 +50,9 @@ if __name__ == "__main__": """ # 初始化SQLite客户端 super().__init__(database="database.db") - try: with self: - # 初始化在保被保险人表(TPA作业系统包括团单、个单和被保险人表,此处直接整合为宽表) + # 初始化被保险人表 self._execute( sql=""" CREATE TABLE IF NOT EXISTS insured_persons @@ -92,12 +74,12 @@ if __name__ == "__main__": --与主被保险人关系,包括本人和附属(附属包括配偶、父母和子女等) relationship TEXT NOT NULL, --保险起期(取个单和团单起期最大值) - commence_date TEXT NOT NULL, + commencement_date TEXT NOT NULL, --保险止期(取个单和团单止期最小值) - terminate_date TEXT NOT NULL, + termination_date TEXT NOT NULL, --联合主键(被保险人+证件类型+证件号码+保险分公司) - PRIMARY KEY (insured_person, identity_type, - identity_number, insurer_company) + PRIMARY KEY (insurer_company, insured_person, identity_type, + identity_number) ) """ ) @@ -127,7 +109,6 @@ if __name__ == "__main__": ) """ ) - except Exception as exception: raise RuntimeError(f"初始化数据库发生异常:{str(exception)}") @@ -153,9 +134,9 @@ if __name__ == "__main__": if result: return result["institution_type"] raise - # TODO: 若购药及就医机构类型为空值则流转至主数据人工处理 + # TODO: 若根据购药及就医机构查询购药及就医机构类型发生异常则流转至主数据人工处理 except Exception: - raise RuntimeError("查询并获取单条购药及就医机构类型发生异常") + raise # noinspection PyShadowingNames def query_insured_persons( @@ -167,13 +148,13 @@ if __name__ == "__main__": report_date: str, ) -> Optional[List[Dict[str, Any]]]: """ - 根据保险分公司、被保险人、证件类型、证件号码和出险时间查询被保险人(备注,若夫妻同在投保公司则互为附加被保险人,一方被保险人记录包括本人和配偶两条) + 根据保险分公司、被保险人、证件类型、证件号码和出险时间查询个单(列表,若夫妻同在一家投保公司且互为附加被保险人,则一方的持有二张个单) :param insurer_company: 保险分公司 :param insured_person: 被保险人 :param identity_type: 证件类型 :param identity_number: 证件号码 :param report_date: 报案时间 - :return: 被保险人列表,包括被被保险人、个单号、主被保险人、与主被保险人关系、保险起期和保险止期 + :return: 个单列表 """ # noinspection PyBroadException try: @@ -181,19 +162,19 @@ if __name__ == "__main__": # noinspection SqlResolve result = self._query_all( sql=""" - SELECT group_policy AS "团单号", - person_policy AS "个单号", - master_insured_person AS "主被保险人", - insured_person AS "被保险人", - relationship AS "与主被保险人关系", - commence_date AS "保险起期", - terminate_date AS "保险止期" + SELECT group_policy, + person_policy, + master_insured_person, + insured_person, + relationship, + commencement_date, + termination_date FROM insured_persons WHERE insurer_company = ? AND insured_person = ? AND identity_type = ? AND identity_number = ? - AND ? BETWEEN commence_date AND terminate_date + AND ? BETWEEN commencement_date AND termination_date """, parameters=( insurer_company, @@ -207,20 +188,18 @@ if __name__ == "__main__": return [ { k: ( - datetime.strptime(v, "%Y-%m-%d") # 保险 - if k in ["保险起期", "保险止期"] + datetime.strptime(v, "%Y-%m-%d") + if k in ["commencement_date", "termination_date"] else v - ) + ) # 若为保险起期、止期则转为日期时间(datetime对象) for k, v in e.items() } for e in result - ] # 将保险起期和保险止期由时间戳转为datetime对象 + ] # 将保险起期和保险止期转为日期(datetime对象) raise - # TODO: 若根据保险分公司、被保险人、证件类型和证件号码查询被保险人发生异常则流转至主数据人工处理 + # TODO: 若根据保险分公司、被保险人、证件类型、证件号码和出险时间查询被保险人发生异常则流转至主数据人工处理 except Exception: - raise RuntimeError( - "根据保险分公司、被保险人、证件类型和证件号码查询被保险人发生异常" - ) + raise # noinspection PyShadowingNames def query_medicine( @@ -232,7 +211,7 @@ if __name__ == "__main__": :param content: 明细项具体内容 :return: 药品/医疗服务 """ - # TODO: 暂仅支持查询药品,后续完善查询医疗服务 + # TODO: 暂仅支持查询药品、通过药品/医疗服务包含明细项中具体内容查询 # noinspection PyBroadException try: with self: @@ -248,11 +227,11 @@ if __name__ == "__main__": if result: return max(result, key=lambda x: len(x["medicine"]))[ "medicine" - ] # 返回药品最大长度的药品 + ] # 返回最大长度的药品/医疗服务 raise # TODO: 若根据明细项中具体内容查询药品/医疗服务发生异常则流转至主数据人工处理 except Exception: - raise RuntimeError("根据明细项中具体内容查询药品/医疗服务发生异常") + raise # 实例化主数据 master_data = MasterData() @@ -269,79 +248,69 @@ if __name__ == "__main__": # ------------------------- # 自定义方法 # ------------------------- - # noinspection PyShadowingNames - def image_read( - image_path: Path, - ) -> Optional[numpy.ndarray | None]: - """ - 本地打开并读取影像件 - :param image_path: 影像件路径对象 - :return: 影像件数组 - """ - # noinspection PyBroadException - try: - # 影像件打开并读取(默认转为单通道灰度图) - image_ndarray = cv2.imread(image_path.as_posix(), cv2.IMREAD_GRAYSCALE) - if image_ndarray is None: - raise RuntimeError("影像件打开并读取发生异常") - - return image_ndarray - except Exception: - # 若本地打开并读取影像件发生异常则抛出异常(实际作业需从影像件服务器下载并读取影像件,因签收时会转存,故必可下载) - raise RuntimeError("影像件打开并读取发生异常") # noinspection PyShadowingNames - def image_serialize(image_format: str, image_ndarray: numpy.ndarray) -> str: + def image_classify(image_index: int, image_path: Path) -> Optional[Tuple[str, str]]: """ - 影像件序列化 - :param image_format: 影像件格式 - :param image_ndarray: 影像件数组 - :return: 影像件唯一标识 + 分类影像件并旋正 + :param image_index: 影像件编号 + :param image_path: 影像件路径(path对象) + :return: 无 """ - # 按照影像件格式就影像件数组编码 - success, image_ndarray_encoded = cv2.imencode(image_format, image_ndarray) - if not success or image_ndarray_encoded is None: - raise RuntimeError("编码为图像字节数组发生异常") - # 将编码后图像数组转为字节流 - image_bytes = image_ndarray_encoded.tobytes() - # 生成影像件唯一标识 - image_guid = md5(image_bytes).hexdigest().upper() - return image_guid + # noinspection PyShadowingNames + def image_read( + image_path: Path, + ) -> Optional[numpy.ndarray | None]: + """ + 打开并读取影像件 + :param image_path: 影像件路径(path对象) + :return: 影像件数据(numpy.ndarray对象) + """ + # noinspection PyBroadException + try: + # 打开并读取影像件(默认转为单通道灰度图) + image_ndarray = cv2.imread(image_path.as_posix(), cv2.IMREAD_GRAYSCALE) + if image_ndarray is None: + raise + return image_ndarray + except Exception as exception: + raise RuntimeError(f"打开并读取影像件发生异常:{str(exception)}") - # noinspection PyShadowingNames - def image_classify( - image_guid: str, image_format: str, image_ndarray: numpy.ndarray - ) -> Optional[Tuple[str, str]]: - """ - 影像件分类并旋正 - :param image_guid: 影像件唯一标识 - :param image_format: 影像件格式 - :param image_ndarray: 影像件数据 - :return: 压缩后影像件BASE64编码和影像件类型 - """ + # noinspection PyShadowingNames + def image_serialize(image_format: str, image_ndarray: numpy.ndarray) -> str: + """ + 生成影像件唯一标识 + :param image_format: 影像件格式 + :param image_ndarray: 影像件数据 + :return: 影像件唯一标识 + """ + success, image_ndarray_encoded = cv2.imencode(image_format, image_ndarray) + if not success or image_ndarray_encoded is None: + raise RuntimeError("编码影像件发生异常") + + # 转为字节流并生成影像件唯一标识 + image_guid = md5(image_ndarray_encoded.tobytes()).hexdigest().upper() + return image_guid # noinspection PyShadowingNames def image_compress( image_format, image_ndarray, image_size_specified=2 ) -> Optional[str]: """ - 影像件压缩 - :param image_ndarray: 影像件数组 + 压缩影像件 :param image_format: 影像件格式 - :param image_size_specified: 指定影像件大小,单位为兆字节(MB) + :param image_ndarray: 影像件数据 + :param image_size_specified: 指定压缩影像件大小,单位为兆字节(MB) :return: 压缩后影像件BASE64编码 """ - # 将指定影像件大小单位由兆字节转为字节 + # 转为字节 image_size_specified = image_size_specified * 1024 * 1024 - # 通过调整影像件质量和尺寸达到压缩影像件目的 - # 外循环压缩:通过调整影像件质量实现压缩影像件大小 + # 通过调整影像件质量和尺寸达到压缩影像件目的(先调整影像件质量再调整影像件尺寸) for quality in range(100, 50, -10): image_ndarray_copy = image_ndarray.copy() - # 内循环压缩:通过调整影像件尺寸实现压缩影像件大小 - for i in range(10): - # 按照影像件格式和影像件质量将影像件数组编码 + for _ in range(10): success, image_ndarray_encoded = cv2.imencode( image_format, image_ndarray_copy, @@ -351,7 +320,6 @@ if __name__ == "__main__": else [cv2.IMWRITE_JPEG_QUALITY, quality] ), ) - # 若编码发生异常则停止循环 if not success or image_ndarray_encoded is None: break @@ -362,7 +330,6 @@ if __name__ == "__main__": if len(image_base64) <= image_size_specified: return image_base64 - # 调整影像件尺寸 image_ndarray_copy = cv2.resize( image_ndarray_copy, ( @@ -371,19 +338,28 @@ if __name__ == "__main__": ), interpolation=cv2.INTER_AREA, ) - # 若调整后影像件尺寸中长或宽小于350像素则停止调整影像件尺寸 + # 若调整影像件尺寸后宽/高小于350像素则终止循环 if min(image_ndarray_copy.shape[:2]) < 350: break return None - # 影像件压缩 + # 打开并读取影像件 + image_ndarray = image_read(image_path) + image_index = f"{image_index:02d}" + image_format = image_path.suffix.lower() # 影像件格式 + + # 生成影像件唯一标识 + # noinspection PyTypeChecker + image_guid = image_serialize(image_format, image_ndarray) + + # 压缩影像件 image_base64 = image_compress( image_format, image_ndarray, image_size_specified=2 - ) # 深圳快瞳要求为2兆字节 - # TODO: 若影像件压缩发生异常则流转至人工处理 - if image_base64 is None: - raise RuntimeError("影像件压缩发生异常") + ) # 深圳快瞳要求影像件BASE64编码后大小小于等于2兆字节 + # TODO: 若压缩影像件发生异常则流转至人工处理 + if not image_base64: + raise # 请求深圳快瞳影像件分类接口 response = http_client.post( @@ -399,12 +375,11 @@ if __name__ == "__main__": }, guid=md5((url + image_guid).encode("utf-8")).hexdigest().upper(), ) - # 若响应非成功则抛出异常 # TODO: 若响应非成功则流转至人工处理 if not (response.get("status") == 200 and response.get("code") == 0): - raise RuntimeError("请求深圳快瞳影像件分类接口发生异常") + raise - # 解析影像件类型 + # 匹配影像件类型 # noinspection PyTypeChecker match (response["data"]["flag"], response["data"]["type"]): case (14, _): @@ -432,7 +407,7 @@ if __name__ == "__main__": case _: image_type = "其它" - # 解析影像件方向 + # 匹配影像件方向 # noinspection PyTypeChecker image_orientation = { "0": "0度", @@ -450,15 +425,25 @@ if __name__ == "__main__": "逆时针90度": cv2.ROTATE_90_CLOCKWISE, # 顺时针旋转90度 }[image_orientation], ) - # 旋正后影像件再次压缩 + # 旋正后再次压缩影像件 image_base64 = image_compress( image_format, image_ndarray, image_size_specified=2 ) - # TODO: 若旋正后影像件再次压缩发生异常则流转至人工处理 - if image_base64 is None: - raise RuntimeError("旋正后影像件再次压缩发生异常") + # TODO: 若旋正后再次压缩影像件发生异常则流转至人工处理 + if not image_base64: + raise - return image_base64, image_type + dossier["images_layer"].append( + { + "image_index": image_index, + "image_path": image_path.as_posix(), + "image_name": image_path.stem, + "image_format": image_format, + "image_guid": image_guid, + "image_base64": image_base64, + "image_type": image_type, + } + ) # noinspection PyShadowingNames def image_recognize( @@ -466,74 +451,119 @@ if __name__ == "__main__": insurer_company, ) -> None: """ - 影像件识别并整合至赔案档案 + 识别影像件并整合至赔案档案 :param image: 影像件 :param insurer_company: 保险分公司 - :return: 空 + :return: 无 """ # TODO: 后续添加居民身份证(国徽面)和居民身份证(头像面)合并 # noinspection PyShadowingNames def identity_card_recognize(image, insurer_company) -> None: """ - 居民身份证识别并整合至赔案档案 + 识别居民身份证并整合至赔案档案 :param image: 影像件 :param insurer_company: 保险分公司 - :return: 空 + :return: 无 """ # noinspection PyShadowingNames - def calculate_age(report_time: datetime, birthday: datetime) -> int: + def calculate_age(report_time: datetime, birth_date: datetime) -> int: """ - 按照报案时间计算周岁 + 根据报案时间计算周岁 :param report_time: 报案时间 - :param birthday: 出生日期 + :param birth_date: 出生日期 :return 周岁 """ - # 年龄 - age = report_time.year - birthday.year + age = report_time.year - birth_date.year - # 若报案时未到生日则年龄减去1 - if (report_time.month, report_time.day) < ( - birthday.month, - birthday.day, - ): - age -= 1 - - return age + return ( + age - 1 + if (report_time.month, report_time.day) + < ( + birth_date.month, + birth_date.day, + ) + else age + ) # 若报案时间的月日小于生成日期的月日则前推一年 # 请求深圳快瞳居民身份证识别接口 response = http_client.post( url=(url := "https://ai.inspirvision.cn/s/api/ocr/identityCard"), headers={ - "X-RequestId-Header": image["影像件唯一标识"] + "X-RequestId-Header": image["image_guid"] }, # 以影像件唯一标识作为请求唯一标识,用于双方联查 data={ "token": authenticator.get_token( servicer="szkt" ), # 获取深圳快瞳访问令牌 - "imgBase64": f"data:image/{image["影像件格式"].lstrip(".")};base64,{image["影像件BASE64编码"]}", + "imgBase64": f"data:image/{image["image_format"].lstrip(".")};base64,{image["image_base64"]}", # 影像件BASE64编码嵌入数据统一资源标识符 }, # 深圳快瞳支持同时识别居民国徽面和头像面 - guid=md5((url + image["影像件唯一标识"]).encode("utf-8")) + guid=md5((url + image["image_guid"]).encode("utf-8")) .hexdigest() .upper(), ) - # TODO: 若响应非成功则流转至人工处理 + # TODO: 若请求深圳快瞳居民身份证识别接口发生异常则流转至人工处理 if not (response.get("status") == 200 and response.get("code") == 0): - raise RuntimeError("请求深圳快瞳居民身份证识别接口发生异常") + raise - if image["影像件类型"] in [ + if image["image_type"] in [ + "居民身份证(国徽、头像面)", + "居民身份证(头像面)", + ]: + # noinspection PyTypeChecker + dossier["insured_person_layer"].update( + { + "insured_person": ( + insured_person := response["data"]["name"] + ), # 被保险人 + "identity_type": (identity_type := "居民身份证"), # 证件类型 + "identity_number": ( + indentity_number := response["data"]["idNo"] + ), # 证件号码 + "gender": response["data"]["sex"], # 性别 + "birth_date": ( + birth_date := datetime.strptime( + response["data"]["birthday"], "%Y-%m-%d" + ) + ), # 出生日期,转为日期时间(datetime对象),格式默认为%Y-%m-%d + "age": calculate_age( + dossier["report_layer"]["report_time"], birth_date + ), # 年龄 + "province": ( + residential_address := parse_location( + response["data"]["address"] + ) + ).get( + "province" + ), # 就住址解析为所在省、市、区和详细地址 + "city": residential_address.get("city"), + "district": residential_address.get("county"), + "detailed_address": residential_address.get("detail"), + } + ) + + # 根据保险分公司、被保险人、证件类型、证件号码和出险时间查询个单 + dossier["person_policies_layer"] = master_data.query_insured_persons( + insurer_company, + insured_person, + identity_type, + indentity_number, + dossier["report_layer"]["report_time"].strftime("%Y-%m-%d"), + ) + + if image["image_type"] in [ "居民身份证(国徽、头像面)", "居民身份证(国徽面)", ]: # noinspection PyTypeChecker - dossier["出险人层"].update( + dossier["insured_person_layer"].update( { - "有效起期": datetime.strptime( + "commencement_date": datetime.strptime( (period := response["data"]["validDate"].split("-"))[0], "%Y.%m.%d", ), # 就有效期限解析为有效起期和有效止期。其中,若有效止期为长期则默认为9999-12-31 - "有效止期": ( + "termination_date": ( datetime(9999, 12, 31) if period[1] == "长期" else datetime.strptime(period[1], "%Y.%m.%d") @@ -541,52 +571,13 @@ if __name__ == "__main__": } ) - if image["影像件类型"] in [ - "居民身份证(国徽、头像面)", - "居民身份证(头像面)", - ]: - # noinspection PyTypeChecker - dossier["出险人层"].update( - { - "出险人": (insured_person := response["data"]["name"]), - "证件类型": (identity_type := "居民身份证"), - "证件号码": (indentity_number := response["data"]["idNo"]), - "性别": response["data"]["sex"], - "出生日期": ( - birthday := datetime.strptime( - response["data"]["birthday"], "%Y-%m-%d" - ) - ), # 深圳快瞳居民身份证识别接口中出生由字符串转为日期,日期格式默认为%Y-%m-%d - "年龄": calculate_age( - dossier["报案层"]["报案时间"], birthday - ), # 按照报案时间计算周岁 - "所在省": ( - address := parse_location(response["data"]["address"]) - ).get( - "province" - ), # 就住址解析为省、市、区和详细地址 - "所在市": address.get("city"), - "所在区": address.get("county"), - "详细地址": address.get("detail"), - } - ) - - # 根据保险分公司、被保险人、证件类型、证件号码和出险时间查询被保险人 - dossier["被保险人层"] = master_data.query_insured_persons( - insurer_company, - insured_person, # 出险人和被保险人为同一人,视角不同:出险人为理赔,被保险人为承保/保全 - identity_type, - indentity_number, - dossier["报案层"]["报案时间"].strftime("%Y-%m-%d"), - ) - # noinspection PyShadowingNames def application_recognize(image, insurer_company) -> None: """ - 理赔申请书识别并整合至赔案档案 + 识别理赔申请书并整合至赔案档案 :param image: 影像件 :param insurer_company: 保险分公司 - :return: 空 + :return: 无 """ # noinspection PyShadowingNames @@ -594,10 +585,10 @@ if __name__ == "__main__": """ 使用多模态大模型就理赔申请书进行光学字符识别并结构化识别结果 :param image: 影像件 - :param schema: 识别结果的JSON格式 - :return: 识别结果 + :param schema: JSON格式 + :return: 结构化后识别结果 """ - # 尝试请求火山引擎多模态大模型接口至就消息内容JSON反序列化 + # 请求火山引擎多模态大模型接口并就消息内容JSON反序列化 response = http_client.post( url="https://ark.cn-beijing.volces.com/api/v3/chat/completions", headers={ @@ -614,8 +605,8 @@ if __name__ == "__main__": { "type": "image_url", "image_url": { - "url": f"data:image/{image["影像件格式"].lstrip(".")};base64,{image["影像件BASE64编码"]}" - }, + "url": f"data:image/{image["image_format"].lstrip(".")};base64,{image["image_base64"]}" + }, # 影像件BASE64编码嵌入数据统一资源标识符 }, { "type": "text", @@ -649,7 +640,7 @@ if __name__ == "__main__": .upper(), ) - # 尝试就响应中消息内容JSON反序列化 + # 就响应中消息内容JSON反序列化 # noinspection PyBroadException try: # noinspection PyTypeChecker @@ -660,11 +651,11 @@ if __name__ == "__main__": # noinspection PyShadowingNames def boc_application_recognize(image: str) -> None: """ - 就中银保险有限公司的理赔申请书识别并整合至赔案档案 + 识别中银保险有限公司的理赔申请书并整合至赔案档案 :param image: 影像件 - :return: 空 + :return: 无 """ - # 识别结果的JSON格式 + # JSON格式 schema = { "type": "object", "description": "识别结果对象", @@ -758,31 +749,25 @@ if __name__ == "__main__": "开户银行", "户名", "账号", - ], # 识别结果的JSON结构必须字段 - "additionalProperties": False, # 禁止就识别结果的JSON结构新增属性 + ], # JSON结构必须字段 + "additionalProperties": False, # 禁止就JSON结构新增属性 } # 使用多模态大模型就理赔申请书进行光学字符识别并结构化识别结果 recognition = mlm_recognize(image, schema) - # TODO: 若非成功则流转至人工处理 - if recognition is None: - raise RuntimeError( - "就中银保险有限公司的理赔申请书识别并整合至赔案档案发生异常" - ) - dossier["出险人层"].update( + # TODO: 若识别中银保险有限公司的理赔申请书并整合至赔案档案发生异常则流转至人工处理 + if not recognition: + raise + dossier["insured_person_layer"].update( { - "手机号": recognition["手机"], - } - ) - dossier["领款人层"].update( - { - "领款人": recognition["户名"], - "银行": recognition["开户银行"], - "账号": recognition["账号"], + "phone_number": recognition["手机"], + "account": recognition["户名"], + "account_bank": recognition["开户银行"], + "account_number": recognition["账号"], } ) - # 根据保险分公司匹配结构化识别文本方法 + # 根据保险分公司匹配处理方法 match insurer_company: # 中银保险有限公司 case _ if insurer_company.startswith("中银保险有限公司"): @@ -791,7 +776,7 @@ if __name__ == "__main__": # noinspection PyShadowingNames def receipt_recognize(image, insurer_company) -> None: """ - 票据识别并整合至赔案档案 + 识别票据并整合至赔案档案 :param image: 影像件 :param insurer_company: 保险分公司 :return: 空 @@ -800,7 +785,7 @@ if __name__ == "__main__": # noinspection PyShadowingNames def fuzzy_match(contents: list, key: str) -> Optional[str]: """ - 根据指定内容列表(基于深圳快瞳增值税发票和医疗收费票据识别结果)模糊匹配键名并获取值 + 根据内容列表(基于深圳快瞳增值税发票和医疗收费票据识别结果)模糊匹配键名 :param contents: 内容列表 :param key: 键名 :return 值 @@ -832,7 +817,7 @@ if __name__ == "__main__": (result[0] if result[0] else None) if (result := max(candidates, key=lambda x: x[1]))[1] >= 80 else None - ) # 返回>=80且最大的相似度的值 + ) # 返回似度>=80且最大的值 # 对应深圳快瞳医疗收费票据识别结果 case _ if "name" in contents[0].keys(): @@ -861,42 +846,42 @@ if __name__ == "__main__": else None ) # 返回>=80且最大的相似度的值 - def parse_item(name: str) -> Tuple[str, Optional[str]]: + def parse_item(item: str) -> Tuple[str, Optional[str]]: """ - 根据明细项解析明细项类别和具体内容,并具体内容查询药品/医疗服务 - :param name: 明细项 + 根据明细项解析明细项类别和具体内容,并根据具体内容查询药品/医疗服务 + :param item: 明细项 return 明细项类别和药品/医疗服务 """ if match := re.match( r"^\*(?P.*?)\*(?P.*)$", - name, + item, ): return match.group("category"), master_data.query_medicine( match.group("specific") ) # 一般增值税发票明细项格式形如*{category}*{specific},其中category为明细项类别,例如中成药;specific为明细项具体内容,例如[同仁堂]金贵肾气水蜜丸 300丸/瓶,需要据此查询药品。其它格式则将明细项内容作为明细项类别,药品为空值 else: - return name, None + return item, None # 初始化票据数据 - receipt = {"影像件编号": image["影像件编号"]} + receipt = {"image_index": image["image_index"]} # 请求深圳快瞳票据查验接口(兼容增值税发票、医疗门诊/住院收费票据) response = http_client.post( url=(url := "https://ai.inspirvision.cn/s/api/ocr/invoiceCheckAll"), headers={ - "X-RequestId-Header": image["影像件唯一标识"] + "X-RequestId-Header": image["image_guid"] }, # 以影像件唯一标识作为请求唯一标识,用于双方联查 data={ "token": authenticator.get_token( servicer="szkt" ), # 获取深圳快瞳访问令牌 - "imgBase64": f"data:image/{image["影像件格式"].lstrip(".")};base64,{image["影像件BASE64编码"]}", + "imgBase64": f"data:image/{image["image_format"].lstrip(".")};base64,{image["image_base64"]}", # 影像件BASE64编码嵌入数据统一资源标识符 }, - guid=md5((url + image["影像件唯一标识"]).encode("utf-8")) + guid=md5((url + image["image_guid"]).encode("utf-8")) .hexdigest() .upper(), ) - # 若查验结果为真票或红票则直接整合至赔案档案 + # 若查验状态为真票或红票则直接整合至赔案档案 if response.get("status") == 200 and response.get("code") == 10000: # noinspection PyTypeChecker match response["data"]["productCode"]: @@ -905,34 +890,36 @@ if __name__ == "__main__": # noinspection PyTypeChecker receipt.update( { - "查验状态": ( + "verification": ( "真票" if response["data"]["details"]["invoiceTypeNo"] == "0" else "红票" ), # 红票为状态为失控、作废、已红冲、部分红冲和全额红冲的票据 - "票据号": response["data"]["details"]["number"], - "票据代码": ( + "number": response["data"]["details"]["number"], + "code": ( response["data"]["details"]["code"] if response["data"]["details"]["code"] else None ), - "开票日期": datetime.strptime( + "date": datetime.strptime( response["data"]["details"]["date"], "%Y年%m月%d日" - ), # 深圳快瞳票据查验接口中开票日期由字符串转为datetime对象 - "校验码": response["data"]["details"]["check_code"], - "开票金额": Decimal( + ), # 转为日期时间(datetime对象) + "verification_code": response["data"]["details"][ + "check_code" + ], + "amount": Decimal( response["data"]["details"]["total"] ).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), # 深圳快瞳票据查验接口中开票金额由字符串转为Decimal,保留两位小数 - "姓名": response["data"]["details"]["buyer"], - "购药及就医机构": response["data"]["details"]["seller"], - "明细项": [ + "payer": response["data"]["details"]["buyer"], + "institution": response["data"]["details"]["seller"], + "items": [ { - "明细项": item["name"], - "数量": ( + "item": item["name"], + "quantity": ( Decimal(item["quantity"]).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, @@ -940,7 +927,7 @@ if __name__ == "__main__": if item["quantity"] else Decimal("0.00") ), # 深圳快瞳票据查验接口中明细单位由空字符转为None,若非空字符由字符串转为Decimal,保留两位小数 - "金额": ( + "amount": ( Decimal(item["total"]) + Decimal(item["tax"]) ).quantize( @@ -950,7 +937,7 @@ if __name__ == "__main__": } for item in response["data"]["details"]["items"] ], - "备注": ( + "remarks": ( response["data"]["details"]["remark"] if response["data"]["details"]["remark"] else None @@ -962,17 +949,17 @@ if __name__ == "__main__": # noinspection PyTypeChecker receipt.update( { - "查验状态": ( + "verification": ( "真票" if response["data"]["flushedRed"] == "true" else "红票" ), - "票据号": response["data"]["billNumber"], - "票据代码": response["data"]["billCode"], - "开票日期": datetime.strptime( + "number": response["data"]["billNumber"], + "code": response["data"]["billCode"], + "date": datetime.strptime( response["data"]["invoiceDate"], "%Y-%m-%d %H:%M:%S" - ), # 深圳快瞳票据查验接口中开票日期由字符串转为datetime对象 - "入院日期": ( + ), # 转为日期时间(datetime对象) + "admission_date": ( datetime.strptime( response["data"]["hospitalizationDate"].split( "-" @@ -982,7 +969,7 @@ if __name__ == "__main__": if response["data"]["hospitalizationDate"] else None ), # 深圳快瞳票据查验接口中住院日期解析为入院日期和出院日期 - "出院日期": ( + "discharge_date": ( datetime.strptime( response["data"]["hospitalizationDate"].split( "-" @@ -992,32 +979,30 @@ if __name__ == "__main__": if response["data"]["hospitalizationDate"] else None ), - "校验码": response["data"]["checkCode"], - "开票金额": Decimal( - response["data"]["amount"] - ).quantize( + "verification_code": response["data"]["checkCode"], + "amount": Decimal(response["data"]["amount"]).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "姓名": response["data"]["payer"], - "购药及就医机构": response["data"][ + "payer": response["data"]["payer"], + "institution": response["data"][ "receivablesInstitution" ], - "明细项": [ + "items": [ { - "明细项": item["itemName"], - "数量": Decimal(item["number"]).quantize( + "item": item["itemName"], + "quantity": Decimal(item["number"]).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "金额": Decimal(item["totalAmount"]).quantize( + "amount": Decimal(item["totalAmount"]).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), } for item in response["data"]["feeitems"] ], - "个人自费": Decimal( + "personal_self_payment": Decimal( response["data"]["personalExpense"] if response["data"]["personalExpense"] else Decimal("0.00") @@ -1025,7 +1010,7 @@ if __name__ == "__main__": Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "个人自付": Decimal( + "non_medical_payment": Decimal( response["data"]["personalPay"] if response["data"]["personalPay"] else Decimal("0.00") @@ -1033,7 +1018,7 @@ if __name__ == "__main__": Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "医保支付": ( + "medical_payment": ( Decimal(response["data"]["medicarePay"]) if response["data"]["medicarePay"] else Decimal("0.00") @@ -1048,16 +1033,16 @@ if __name__ == "__main__": ), # 包括医保统筹基金支付和其它支付(例如,退休补充支付) } ) - # 若查验结果为假票或无法查验则再请求深圳快瞳票据识别接口接整合至赔案档案 + # 若查验状态为假票或无法查验则再请求深圳快瞳票据识别接口接整合至赔案档案 else: - receipt["查验结果"] = ( + receipt["verification"] = ( "假票" if response.get("status") == 400 and (response.get("code") == 10100 or response.get("code") == 10001) else "无法查验" ) # 假票:查无此票或查验成功五要素不一致 - match image["影像件类型"]: + match image["image_type"]: case "增值税发票": # 请求深圳快瞳增值税发票识别接口 response = http_client.post( @@ -1065,43 +1050,43 @@ if __name__ == "__main__": url := "https://ai.inspirvision.cn/s/api/ocr/vatInvoice" ), headers={ - "X-RequestId-Header": image["影像件唯一标识"] + "X-RequestId-Header": image["image_guid"] }, # 以影像件唯一标识作为请求唯一标识,用于双方联查 data={ "token": authenticator.get_token( servicer="szkt" ), # 获取深圳快瞳访问令牌 - "imgBase64": f"data:image/{image["影像件格式"].lstrip(".")};base64,{image["影像件BASE64编码"]}", + "imgBase64": f"data:image/{image["image_format"].lstrip(".")};base64,{image["image_base64"]}", # 影像件BASE64编码嵌入数据统一资源标识符 }, - guid=md5((url + image["影像件唯一标识"]).encode("utf-8")) + guid=md5((url + image["image_guid"]).encode("utf-8")) .hexdigest() .upper(), ) - # TODO: 若响应非成功则流转至人工处理 + # TODO: 若请求深圳快瞳增值税发票识别接口发生异常则流转至人工处理 if not ( response.get("status") == 200 and response.get("code") == 0 ): - raise RuntimeError("请求深圳快瞳增值税发票识别接口发生异常") + raise match fuzzy_match(response["data"], "发票类型"): case "电子发票(普通发票)": # noinspection PyTypeChecker receipt.update( { - "票据号": fuzzy_match( + "number": fuzzy_match( response["data"], "发票号码" ), - "票据代码": fuzzy_match( + "code": fuzzy_match( response["data"], "发票代码" ), - "开票日期": datetime.strptime( + "date": datetime.strptime( fuzzy_match(response["data"], "开票日期"), "%Y年%m月%d日", ), - "校验码": fuzzy_match( + "verification_code": fuzzy_match( response["data"], "校验码" ), - "开票金额": Decimal( + "amount": Decimal( fuzzy_match( response["data"], "小写金额" ).replace("¥", "") @@ -1109,20 +1094,20 @@ if __name__ == "__main__": Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "姓名": fuzzy_match( + "payer": fuzzy_match( response["data"], "购买方名称" ), - "购药及就医机构": fuzzy_match( + "institution": fuzzy_match( response["data"], "销售方名称" ), - "明细项": [ + "items": [ { - "明细项": name, - "数量": Decimal(quantity).quantize( + "item": name, + "quantity": Decimal(quantity).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "金额": ( + "amount": ( Decimal(amount) + Decimal(tax) ).quantize( Decimal("0.00"), @@ -1164,27 +1149,29 @@ if __name__ == "__main__": ], ) ], - "备注": fuzzy_match(response["data"], "备注"), + "remarks": fuzzy_match( + response["data"], "备注" + ), } ) case "增值税普通发票(卷票)": # noinspection PyTypeChecker receipt.update( { - "票据号": fuzzy_match( + "number": fuzzy_match( response["data"], "发票号码" ), - "票据代码": fuzzy_match( + "code": fuzzy_match( response["data"], "发票代码" ), - "开票日期": datetime.strptime( + "date": datetime.strptime( fuzzy_match(response["data"], "开票日期"), "%Y-%m-%d", ), - "校验码": fuzzy_match( + "verification_code": fuzzy_match( response["data"], "校验码" ), - "开票金额": Decimal( + "amount": Decimal( fuzzy_match( response["data"], "合计金额(小写)" ).replace("¥", "") @@ -1192,20 +1179,20 @@ if __name__ == "__main__": Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "姓名": fuzzy_match( + "payer": fuzzy_match( response["data"], "购买方名称" ), - "购药及就医机构": fuzzy_match( + "institution": fuzzy_match( response["data"], "销售方名称" ), - "明细项": [ + "items": [ { - "明细项": name, - "数量": Decimal(quantity).quantize( + "item": name, + "quantity": Decimal(quantity).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "金额": Decimal(amount).quantize( + "amount": Decimal(amount).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), # 深圳快瞳票据识别接口中明细的金额和税额由字符串转为Decimal,保留两位小数,并求和 @@ -1237,7 +1224,9 @@ if __name__ == "__main__": ], ) ], - "备注": fuzzy_match(response["data"], "备注"), + "remarks": fuzzy_match( + response["data"], "备注" + ), } ) case "医疗门诊收费票据" | "医疗住院收费票据": @@ -1245,35 +1234,33 @@ if __name__ == "__main__": response = http_client.post( url=(url := "https://ai.inspirvision.cn/s/api/ocr/medical"), headers={ - "X-RequestId-Header": image["影像件唯一标识"] + "X-RequestId-Header": image["image_guid"] }, # 以影像件唯一标识作为请求唯一标识,用于双方联查 data={ "token": authenticator.get_token( servicer="szkt" ), # 获取深圳快瞳访问令牌 - "imgBase64": f"data:image/{image["影像件格式"].lstrip(".")};base64,{image["影像件BASE64编码"]}", + "imgBase64": f"data:image/{image["image_format"].lstrip(".")};base64,{image["image_base64"]}", # 影像件BASE64编码嵌入数据统一资源标识符 }, - guid=md5((url + image["影像件唯一标识"]).encode("utf-8")) + guid=md5((url + image["image_guid"]).encode("utf-8")) .hexdigest() .upper(), ) - # TODO: 若响应非成功则流转至人工处理 + # TODO: 若请求深圳快瞳医疗收费票据识别接口发生异常则流转至人工处理 if not ( response.get("status") == 200 and response.get("code") == 0 ): - raise RuntimeError( - "请求深圳快瞳医疗收费票据识别接口发生异常" - ) + raise # noinspection PyTypeChecker receipt.update( { - "票据号": ( + "number": ( receipt := ( response["data"]["insured"][ ( "receipt_hospitalization" - if image["影像件类型"] + if image["image_type"] == "医疗门诊收费票据" else "receipt_outpatient" ) @@ -1282,47 +1269,47 @@ if __name__ == "__main__": )["receipt_no"][ "value" ], # 默认为第一张票据 - "票据代码": receipt["global_detail"]["invoice_code"][ + "code": receipt["global_detail"]["invoice_code"][ "value" ], - "开票日期": datetime.strptime( + "date": datetime.strptime( receipt["global_detail"]["invoice_date"]["value"], "%Y-%m-%d", ), - "入院日期": ( + "admission_date": ( datetime.strptime( receipt["starttime"]["value"], "%Y-%m-%d" ) if isinstance(receipt["starttime"], dict) else None ), - "出院日期": ( + "discharge_date": ( datetime.strptime( receipt["endtime"]["value"], "%Y-%m-%d" ) if isinstance(receipt["endtime"], dict) else None ), - "校验码": fuzzy_match( + "verification_code": fuzzy_match( receipt["global_detail"]["region_specific"], "校验码", ), - "开票金额": Decimal( + "amount": Decimal( receipt["total_amount"]["value"] ).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "姓名": receipt["name"]["value"], - "购药及就医机构": receipt["hospital_name"]["value"], - "明细项": [ + "payer": receipt["name"]["value"], + "institution": receipt["hospital_name"]["value"], + "items": [ { - "明细项": ( - item["item_name"]["value"] - if isinstance(item["item_name"], dict) + "item": ( + item["item"]["value"] + if isinstance(item["item"], dict) else None ), - "数量": Decimal( + "quantity": Decimal( item["number"]["value"] if isinstance(item["number"], dict) else Decimal("1.00") @@ -1330,7 +1317,7 @@ if __name__ == "__main__": Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "金额": Decimal( + "amount": Decimal( item["total_amount"]["value"] if isinstance(item["total_amount"], dict) else Decimal("1.00") @@ -1342,19 +1329,19 @@ if __name__ == "__main__": for item in receipt["feeitems"] if isinstance(item, dict) ], - "个人自费": ( + "personal_self_payment": ( Decimal(receipt["self_cost"]["value"]).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ) ), - "个人自付": ( + "non_medical_payment": ( Decimal(receipt["self_pay"]["value"]).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ) ), - "医保支付": ( + "medical_payment": ( Decimal( receipt["medicare_pay"]["value"] ) # 医保基金统筹支付 @@ -1376,82 +1363,84 @@ if __name__ == "__main__": ) # 根据购药及就医机构查询购药及就医机构类型 - receipt["购药及就医机构类型"] = master_data.query_institution_type( - receipt["购药及就医机构"] + receipt["institution_type"] = master_data.query_institution_type( + receipt["institution"] ) - # 根据影像件类型和购药及就医机构类型匹配购药及就医类型 - match (image["影像件类型"], receipt["购药及就医机构类型"]): - # 就增值税发票且药店扣除不合理费用、增值税发票且私立医院解析个人自费、个人自付、医保支付、不合理金额和合理金额 + # 根据影像件类型和购药及就医机构类型匹配处理方法 + match (image["image_type"], receipt["institution_type"]): case ("增值税发票", "药店"): items = ( - pandas.DataFrame(receipt["明细项"]) - .groupby("明细项") # 就相同明细项合并数量和金额 - .agg(数量=("数量", "sum"), 金额=("金额", "sum")) + pandas.DataFrame(receipt["items"]) + .groupby("item") # 就相同明细项合并数量和金额 + .agg(quantity=("quantity", "sum"), amount=("amount", "sum")) .loc[ - lambda dataframe: dataframe["金额"] != 0 + lambda dataframe: dataframe["amount"] != 0 ] # 仅保留金额非0的明细项 .reset_index() .pipe( lambda dataframe: dataframe.join( - dataframe["明细项"] + dataframe["item"] .apply( parse_item ) # 根据明细项解析明细项类别和具体内容,并根据具体内容查询药品/医疗服务 .apply( pandas.Series ) # 就明细项类别和药品/医疗服务元组展开为两列 - .rename(columns={0: "类别", 1: "药品/医疗服务"}) + .rename(columns={0: "category", 1: "medicine"}) ) ) .assign( - 合理金额=lambda dataframe: dataframe.apply( + reasonable_amount=lambda dataframe: dataframe.apply( lambda row: Decimal( - deduct_unreasonable_amount.evaluate( - { + rule_engine.evaluate( + decision="扣除明细项不合理费用", + inputs={ "insurer_company": insurer_company, - "category": row["类别"], - "medicine": row["药品/医疗服务"], - "amount": format(row["金额"], ".2f"), - } - )["result"]["reasonable_amount"] + "category": row["category"], + "medicine": row["medicine"], + "amount": row["amount"], + }, + )["reasonable_amount"] ).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), axis="columns", ) - ) # 药店购药明细项不合理费用扣除 + ) # 扣除明细项不合理费用 ) receipt.update( { - "事故起期": receipt["开票日期"], - "事故止期": receipt["开票日期"], - "姓名": ( - dossier["出险人层"]["出险人"] - if dossier["出险人层"]["出险人"] in receipt["姓名"] - else receipt["姓名"] + "payer": ( + dossier["insured_person_layer"]["insured_person"] + if dossier["insured_person_layer"]["insured_person"] + in receipt["payer"] + else None ), - "购药及就医类型": "药店购药", - "事故诊断": "购药拟诊", - "个人自费": Decimal("0.00"), - "个人自付": Decimal("0.00"), - "医保支付": Decimal("0.00"), - "不合理金额": Decimal( - receipt["开票金额"] - items["合理金额"].sum() + "accident": "药店购药", + "diagnosis": "购药拟诊", + "occurrence_date": receipt["date"], + "end_date": receipt["date"], + "personal_self_payment": Decimal("0.00"), + "non_medical_payment": Decimal("0.00"), + "medical_payment": Decimal("0.00"), + "unreasonable_amount": Decimal( + receipt["amount"] - items["reasonable_amount"].sum() ).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "合理金额": Decimal(items["合理金额"].sum()).quantize( + "reasonable_amount": Decimal( + items["reasonable_amount"].sum() + ).quantize( Decimal("0.00"), rounding=ROUND_HALF_UP, ), - "明细项": items.to_dict("records"), + "items": items.to_dict("records"), } ) - # TODO: 后续完善就购药及就医类型为门诊就诊(私立医院)处理 case ("增值税发票", "私立医院"): receipt["购药及就医类型"] = "门诊就医" @@ -1467,26 +1456,28 @@ if __name__ == "__main__": "根据影像件类型和购药及就医机构类型匹配购药及就医类型发生异常" ) - dossier["票据层"].append(receipt) + dossier["receipts_layer"].append(receipt) # noinspection PyShadowingNames def bank_card_recognize(image) -> None: """ - 银行卡识别并整合至赔案档案 + 识别银行卡并整合至赔案档案 :param image: 影像件 :return: 空 """ # 请求深圳快瞳银行卡识别接口 response = http_client.post( url=(url := "https://ai.inspirvision.cn/s/api/ocr/bankCard"), - headers={"X-RequestId-Header": image["影像件唯一标识"]}, + headers={ + "X-RequestId-Header": image["image_guid"] + }, # 以影像件唯一标识作为请求唯一标识,用于双方联查 data={ "token": authenticator.get_token( servicer="szkt" ), # 获取深圳快瞳访问令牌 - "imgBase64": f"data:image/{image["影像件格式"].lstrip(".")};base64,{image["影像件BASE64编码"]}", + "imgBase64": f"data:image/{image["image_format"].lstrip(".")};base64,{image["image_base64"]}", # 影像件BASE64编码嵌入数据统一资源标识符 }, - guid=md5((url + image["影像件唯一标识"]).encode("utf-8")) + guid=md5((url + image["image_guid"]).encode("utf-8")) .hexdigest() .upper(), ) @@ -1499,31 +1490,27 @@ if __name__ == "__main__": ): raise RuntimeError("请求深圳快瞳银行卡识别接口发生异常或非借记卡") # noinspection PyTypeChecker - dossier["出险人层"].update( + dossier["insured_person_layer"].update( { - "手机号": None, - } - ) - # noinspection PyTypeChecker - dossier["领款人层"].update( - { - "领款人": None, - "银行": response["data"]["bankInfo"], - "账号": response["data"]["cardNo"].replace(" ", ""), + "phone_number": None, + "account": None, + "account_bank": response["data"]["bankInfo"], + "account_number": response["data"]["cardNo"].replace(" ", ""), } ) - # 检查影像件识别使能,若影像件不识别则跳过 - if not recognize_enabled.evaluate( - { + # 基于影像件识别使能规则评估 + if not rule_engine.evaluate( + decision="影像件识别使能", + inputs={ "insurer_company": insurer_company, - "image_type": image["影像件类型"], - } - )["result"]["recognize_enabled"]: + "image_type": image["image_type"], + }, + )["recognize_enabled"]: return # 根据影像件类型匹配影像件识别方法 - match image["影像件类型"]: + match image["image_type"]: # TODO: 后续添加居民户口簿识别和整合方法 case "居民户口簿": raise RuntimeError("暂不支持居民户口簿") @@ -1546,21 +1533,20 @@ if __name__ == "__main__": bank_card_recognize(image) # 遍历工作目录中赔案目录并创建赔案档案(模拟自动化域就待自动化任务创建理赔档案) - for case_path in [x for x in directory_path.iterdir() if x.is_dir()]: + for case_path in [x for x in workplace_path.iterdir() if x.is_dir()]: # 初始化赔案档案(保险公司将提供投保公司、保险分公司和报案时间等,TPA作业系统签收后生成赔案号) dossier = { - "报案层": { - "保险分公司": ( + "report_layer": { + "insurer_company": ( insurer_company := "中银保险有限公司苏州分公司" ), # 指定保险分公司 - "报案时间": datetime(2025, 7, 25, 12, 0, 0), # 指定报案时间 - "赔案号": (case_number := case_path.stem), # 设定:赔案目录名称为赔案号 - }, - "影像件层": [], - "出险人层": {}, - "被保险人层": [], - "领款人层": {}, - "票据层": [], + "report_time": datetime(2025, 7, 25, 12, 0, 0), # 指定报案时间 + "case_number": case_path.stem, # 设定:赔案目录名称为赔案号 + }, # 报案层 + "images_layer": [], # 影像件层 + "insured_person_layer": {}, # 出险人层 + "person_policies_layer": [], # 个单层 + "receipts_layer": [], # 票据层 } # 遍历赔案目录中影像件 @@ -1575,34 +1561,11 @@ if __name__ == "__main__": ), 1, ): - # 初始化影像件数据 - image = { - "影像件编号": image_index, - "影像件地址": image_path.as_posix(), # 将影像件路径对象转为字符串 - "影像件名称": image_path.stem, - "影像件格式": (image_format := image_path.suffix.lower()), - } + # 分类影像件并旋正(较初审自动化无使能检查) + image_classify(image_index, image_path) - # 本地打开并读取影像件 - image_ndarray = image_read(image_path) - - # 影像件序列化 - # noinspection PyTypeChecker - image["影像件唯一标识"] = ( - image_guid := image_serialize(image_format, image_ndarray) - ) - - # 影像件分类并旋正(较初审自动化无使能检查) - image_base64, image_type = image_classify( - image_guid, image_format, image_ndarray - ) - image["影像件BASE64编码"] = image_base64 - image["影像件类型"] = image_type - - dossier["影像件层"].append(image) - - # 就影像件按照影像件类型排序 - dossier["影像件层"].sort( + # 就影像件层按照影像件类型排序 + dossier["images_layer"].sort( key=lambda x: [ "居民户口簿", "居民身份证(国徽面)", @@ -1616,25 +1579,28 @@ if __name__ == "__main__": "医疗费用清单", "银行卡", "其它", - ].index(x["影像件类型"]) - ) # 优先居民户口簿、居民身份证、中国港澳台地区及境外护照和理赔申请书以查询被保险人信息 - + ].index(x["image_type"]) + ) # 遍历影像件层中影像件 - for image in dossier["影像件层"]: - # 影像件识别并整合至赔案档案 + for image in dossier["images_layer"]: + # 识别影像件并整合至赔案档案 image_recognize( image, insurer_company, ) # 就票据层按照事故止期和票据号顺序排序 - dossier["票据层"].sort(key=lambda x: (x["事故止期"], x["票据号"])) + dossier["receipts_layer"].sort(key=lambda x: (x["end_date"], x["number"])) # 就 - for receipt in dossier["票据层"]: + for receipt in dossier["receipts_layer"]: print(receipt) - print(dossier["被保险人层"]) - print(dossier["出险人层"]) - print(dossier["领款人层"]) - print(dossier["报案层"]) + print(dossier["report_layer"]) + print(dossier["insured_person_layer"]) + print(dossier["person_policies_layer"]) + + dossier.pop("images_layer") + dossier.pop("receipts_layer") + + print(rule_engine._format_value(dossier)) diff --git a/票据理赔自动化/rules/药店购药明细项不合理费用扣除.json b/票据理赔自动化/rules/扣除明细项不合理费用.json similarity index 100% rename from 票据理赔自动化/rules/药店购药明细项不合理费用扣除.json rename to 票据理赔自动化/rules/扣除明细项不合理费用.json diff --git a/票据理赔自动化/rules/拒付.json b/票据理赔自动化/rules/拒付.json new file mode 100644 index 0000000..3c91532 --- /dev/null +++ b/票据理赔自动化/rules/拒付.json @@ -0,0 +1,226 @@ +{ + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "id": "6716ac00-2b7a-4599-b4a7-2a19a2a63b17", + "name": "decisionTable1", + "type": "decisionTableNode", + "content": { + "rules": [ + { + "_id": "172b5ab5-25c4-4785-9cf9-d106730ee55d", + "_description": "", + "268ca3e3-4f15-4c6a-a8f2-bbb5338c1f43": "true", + "3320dce8-b9ca-415b-8b3c-adcfebb06f79": "\"证件有效起期大于等于报案时间\"", + "3404e5f2-f063-40d1-b27a-d3ecfb41c652": "\"中银保险有限公司苏州分公司\"", + "c5488920-2ae6-4123-92be-cd53cbc401f6": "startOf(date(insured_person_layer.commencement_date), \"day\") >= date(report_layer.report_time)", + "c9e2be65-c7a5-463b-8d62-0a5f503c844e": "\"R001-1\"" + }, + { + "_id": "4497043e-4dfa-4edf-a65f-1fa4a998067d", + "_description": "", + "268ca3e3-4f15-4c6a-a8f2-bbb5338c1f43": "false", + "3320dce8-b9ca-415b-8b3c-adcfebb06f79": "", + "3404e5f2-f063-40d1-b27a-d3ecfb41c652": "\"中银保险有限公司苏州分公司\"", + "c5488920-2ae6-4123-92be-cd53cbc401f6": "startOf(date(insured_person_layer.commencement_date), \"day\") <= date(report_layer.report_time)", + "c9e2be65-c7a5-463b-8d62-0a5f503c844e": "\"R001-0\"" + }, + { + "_id": "bf01e2f6-572f-47f5-8773-d5b81a02e7c8", + "_description": "", + "268ca3e3-4f15-4c6a-a8f2-bbb5338c1f43": "true", + "3320dce8-b9ca-415b-8b3c-adcfebb06f79": "\"证件有效止期小于等于报案时间\"", + "3404e5f2-f063-40d1-b27a-d3ecfb41c652": "\"中银保险有限公司苏州分公司\"", + "c5488920-2ae6-4123-92be-cd53cbc401f6": "endOf(date(insured_person_layer.termination_date), \"day\") <= date(report_layer.report_time)", + "c9e2be65-c7a5-463b-8d62-0a5f503c844e": "\"R002-1\"" + }, + { + "_id": "2a7d3c73-840b-4dc8-8363-07f81fa27579", + "_description": "", + "268ca3e3-4f15-4c6a-a8f2-bbb5338c1f43": "false", + "3320dce8-b9ca-415b-8b3c-adcfebb06f79": "", + "3404e5f2-f063-40d1-b27a-d3ecfb41c652": "\"中银保险有限公司苏州分公司\"", + "c5488920-2ae6-4123-92be-cd53cbc401f6": "endOf(date(insured_person_layer.termination_date), \"day\") >= date(report_layer.report_time)", + "c9e2be65-c7a5-463b-8d62-0a5f503c844e": "\"R002-0\"" + }, + { + "_id": "9fa2cd42-2297-4719-bf35-559026e0dc20", + "_description": "", + "268ca3e3-4f15-4c6a-a8f2-bbb5338c1f43": "true", + "3320dce8-b9ca-415b-8b3c-adcfebb06f79": "\"领款人和被保险人不一致\"", + "3404e5f2-f063-40d1-b27a-d3ecfb41c652": "\"中银保险有限公司苏州分公司\"", + "c5488920-2ae6-4123-92be-cd53cbc401f6": "insured_person_layer.account != insured_person_layer.insured_person", + "c9e2be65-c7a5-463b-8d62-0a5f503c844e": "\"R003-1\"" + }, + { + "_id": "76fe12c1-70fc-4221-9c78-6ea4d2d9af24", + "_description": "", + "268ca3e3-4f15-4c6a-a8f2-bbb5338c1f43": "false", + "3320dce8-b9ca-415b-8b3c-adcfebb06f79": "", + "3404e5f2-f063-40d1-b27a-d3ecfb41c652": "\"中银保险有限公司苏州分公司\"", + "c5488920-2ae6-4123-92be-cd53cbc401f6": "insured_person_layer.account == insured_person_layer.insured_person", + "c9e2be65-c7a5-463b-8d62-0a5f503c844e": "\"R003-0\"" + } + ], + "inputs": [ + { + "id": "3404e5f2-f063-40d1-b27a-d3ecfb41c652", + "name": "保险分公司", + "field": "report_layer.insurer_company" + }, + { + "id": "c5488920-2ae6-4123-92be-cd53cbc401f6", + "name": "规则" + } + ], + "outputs": [ + { + "id": "268ca3e3-4f15-4c6a-a8f2-bbb5338c1f43", + "name": "命中", + "field": "hit" + }, + { + "id": "c9e2be65-c7a5-463b-8d62-0a5f503c844e", + "name": "规则代码", + "field": "code" + }, + { + "id": "3320dce8-b9ca-415b-8b3c-adcfebb06f79", + "name": "命是说明", + "field": "explanation" + } + ], + "hitPolicy": "collect", + "inputField": null, + "outputPath": "hits", + "passThrough": false, + "executionMode": "single" + }, + "position": { + "x": 430, + "y": 292.5 + } + }, + { + "id": "5c628b40-4007-449e-84db-e8bdf3d6899d", + "name": "request", + "type": "inputNode", + "content": { + "schema": "" + }, + "position": { + "x": 110, + "y": 292.5 + } + }, + { + "id": "2ee64660-9abd-4b56-b6ed-97d892041cfd", + "name": "response", + "type": "outputNode", + "content": { + "schema": "" + }, + "position": { + "x": 1390, + "y": 292.5 + } + }, + { + "id": "d0835a6d-72a3-4c21-a3de-b38d962d8518", + "name": "decisionTable2", + "type": "decisionTableNode", + "content": { + "rules": [ + { + "_id": "09f82f88-75be-42b2-bd40-23457ec5aed9", + "_description": "命中规则数为0,例如保险分公司未配置规则", + "722fc57b-5166-4457-b9f0-c3437c6159b4": "len(hits) == 0", + "b2b97b2a-5e59-4c23-a116-7e49e59e65c3": "", + "bd0ca899-7c74-40e4-9b61-1c058ef5e83f": "" + }, + { + "_id": "843e7960-c5d8-4867-a283-b2641d31f3d7", + "_description": "", + "722fc57b-5166-4457-b9f0-c3437c6159b4": "count(hits, #.hit) == 0", + "b2b97b2a-5e59-4c23-a116-7e49e59e65c3": "\"赔付\"", + "bd0ca899-7c74-40e4-9b61-1c058ef5e83f": "" + }, + { + "_id": "e5dbd162-d188-4170-9b6b-9fc7077c47bf", + "_description": "", + "722fc57b-5166-4457-b9f0-c3437c6159b4": "", + "b2b97b2a-5e59-4c23-a116-7e49e59e65c3": "\"拒付\"", + "bd0ca899-7c74-40e4-9b61-1c058ef5e83f": "explanations" + } + ], + "inputs": [ + { + "id": "722fc57b-5166-4457-b9f0-c3437c6159b4", + "name": "命中", + "field": "hits" + } + ], + "outputs": [ + { + "id": "b2b97b2a-5e59-4c23-a116-7e49e59e65c3", + "name": "拒付", + "field": "refuse" + }, + { + "id": "bd0ca899-7c74-40e4-9b61-1c058ef5e83f", + "name": "拒付说明", + "field": "explanation" + } + ], + "hitPolicy": "first", + "inputField": null, + "outputPath": null, + "passThrough": false, + "executionMode": "single" + }, + "position": { + "x": 1070, + "y": 292.5 + } + }, + { + "id": "476b8b59-221c-4ff6-b3e2-dfeec58eee10", + "name": "function", + "type": "functionNode", + "content": { + "source": "import zen from 'zen';\n\n/** @type {Handler} **/\nexport const handler = async (input) => {\n const explanations = input.hits.reduce((arr, obj) => {\n const element = obj[\"explanation\"];\n if (element) {\n arr.push(element);\n }\n return arr; \n }, []);\n return {\n hits: input.hits,\n explanations: explanations,\n };\n};\n", + "omitNodes": true + }, + "position": { + "x": 750, + "y": 292.5 + } + } + ], + "edges": [ + { + "id": "02f2d409-3077-46fd-95f4-6b4b441fa99f", + "type": "edge", + "sourceId": "5c628b40-4007-449e-84db-e8bdf3d6899d", + "targetId": "6716ac00-2b7a-4599-b4a7-2a19a2a63b17" + }, + { + "id": "3c17140a-e97a-40d7-a0d2-f0696051bde8", + "type": "edge", + "sourceId": "d0835a6d-72a3-4c21-a3de-b38d962d8518", + "targetId": "2ee64660-9abd-4b56-b6ed-97d892041cfd" + }, + { + "id": "778027ba-7228-4d94-a778-a26f7c3ea9bb", + "type": "edge", + "sourceId": "6716ac00-2b7a-4599-b4a7-2a19a2a63b17", + "targetId": "476b8b59-221c-4ff6-b3e2-dfeec58eee10" + }, + { + "id": "a8422289-e0cb-4529-8c8a-1edceeac98ae", + "type": "edge", + "sourceId": "476b8b59-221c-4ff6-b3e2-dfeec58eee10", + "targetId": "d0835a6d-72a3-4c21-a3de-b38d962d8518" + } + ] +} \ No newline at end of file