chore: 更新配置和构建脚本
This commit is contained in:
@@ -18,8 +18,9 @@ dependencies = [
|
||||
"pydantic-settings>=2.6.0,<3.0.0",
|
||||
"python-dotenv>=1.0.1,<2.0.0",
|
||||
"email-validator>=2.2.0,<3.0.0",
|
||||
"python-multipart>=0.0.20,<1.0.0",
|
||||
"lightrag-hku>=1.4.16,<1.5.0",
|
||||
"python-multipart>=0.0.20,<1.0.0",
|
||||
"openpyxl>=3.1.5,<4.0.0",
|
||||
"lightrag-hku>=1.4.16,<1.5.0",
|
||||
"qdrant-client>=1.18.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
BIN
server/rules/finance-rules/公司差旅费报销规则.xlsx
Normal file
BIN
server/rules/finance-rules/公司差旅费报销规则.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/远光软件2026费用报销说明手册.pdf
Normal file
BIN
server/rules/finance-rules/远光软件2026费用报销说明手册.pdf
Normal file
Binary file not shown.
259
server/scripts/build_company_travel_default_workbook.py
Normal file
259
server/scripts/build_company_travel_default_workbook.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
OUTPUT_PATH = Path(__file__).resolve().parents[1] / "rules" / "finance-rules" / "公司差旅费报销规则.xlsx"
|
||||
|
||||
ROWS = [
|
||||
(1, "北京", "北京", "", "", 500, 450, 450, 500, ""),
|
||||
(2, "天津", "6 个中心城区、滨海新区、东丽区、西青区、津南区、北辰区、武清区、宝坻区、静海区、蓟县", "", "", 380, 360, 350, 380, ""),
|
||||
(2, "天津", "宁河区", "", "", 320, 300, 280, 320, ""),
|
||||
(3, "河北", "石家庄", "", "", 350, 330, 300, 350, ""),
|
||||
(3, "河北", "张家口、秦皇岛、廊坊、承德、保定", "张家口市;秦皇岛市;承德市", "张家口市:7-9 月、11-3 月;秦皇岛市:7-8 月;承德市:7-9 月", 350, 300, 250, 350, 420),
|
||||
(3, "河北", "雄安新区(不含雄县、安新县、容城县)", "", "", 450, 400, 350, 450, ""),
|
||||
(3, "河北", "其他地区", "", "", 310, 290, 250, 310, ""),
|
||||
(4, "山西", "太原", "", "", 350, 330, 300, 350, ""),
|
||||
(4, "山西", "大同、晋城", "", "", 350, 300, 250, 350, ""),
|
||||
(4, "山西", "临汾", "", "", 330, 300, 250, 330, ""),
|
||||
(4, "山西", "阳泉、长治、晋中", "", "", 310, 290, 250, 310, ""),
|
||||
(4, "山西", "其他地区", "", "", 240, 220, 200, 240, ""),
|
||||
(5, "内蒙古", "呼和浩特", "", "", 350, 330, 300, 350, ""),
|
||||
(5, "内蒙古", "海拉尔市、满洲里市、阿尔山市", "海拉尔市、满洲里市、阿尔山市", "7-9 月", 320, 300, 250, 320, 380),
|
||||
(5, "内蒙古", "二连浩特市", "二连浩特市", "7-9 月", 320, 300, 250, 320, 380),
|
||||
(5, "内蒙古", "额济纳市", "额济纳市", "9-10 月", 320, 300, 250, 320, 380),
|
||||
(5, "内蒙古", "其他地区", "", "", 320, 300, 250, 320, ""),
|
||||
(6, "辽宁", "沈阳", "", "", 350, 330, 300, 350, ""),
|
||||
(6, "辽宁", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(7, "大连", "大连", "大连", "7-9 月", 350, 300, 300, 350, 420),
|
||||
(8, "吉林", "长春", "长春", "7-9 月", 350, 330, 300, 350, 420),
|
||||
(8, "吉林", "吉林、延边州、长白山管理区", "吉林、延边州、长白山管理区", "7-9 月", 350, 300, 250, 350, 420),
|
||||
(8, "吉林", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||
(9, "黑龙江", "哈尔滨", "哈尔滨", "7-9 月", 350, 330, 300, 350, 420),
|
||||
(9, "黑龙江", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "6-8 月", 300, 280, 250, 300, 360),
|
||||
(9, "黑龙江", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||
(10, "上海", "上海", "", "", 500, 450, 450, 500, ""),
|
||||
(11, "江苏", "南京", "", "", 380, 350, 350, 380, ""),
|
||||
(11, "江苏", "苏州、无锡、常州、镇江", "", "", 350, 300, 250, 380, ""),
|
||||
(11, "江苏", "其他地区", "", "", 350, 300, 250, 360, ""),
|
||||
(12, "浙江", "杭州", "", "", 400, 350, 350, 400, ""),
|
||||
(12, "浙江", "其他地区", "", "", 340, 300, 250, 340, ""),
|
||||
(13, "宁波", "宁波", "", "", 350, 300, 250, 350, ""),
|
||||
(14, "安徽", "合肥", "", "", 350, 330, 300, 350, ""),
|
||||
(14, "安徽", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||
(15, "福建", "福州", "", "", 380, 350, 300, 380, ""),
|
||||
(15, "福建", "泉州、平潭综合实验区", "", "", 350, 300, 250, 380, ""),
|
||||
(15, "福建", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||
(16, "厦门", "厦门", "", "", 400, 380, 350, 400, ""),
|
||||
(17, "江西", "南昌", "", "", 350, 330, 300, 350, ""),
|
||||
(17, "江西", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||
(18, "山东", "济南", "", "", 380, 350, 300, 380, ""),
|
||||
(18, "山东", "烟台、威海、日照", "烟台、威海、日照", "7-9 月", 350, 300, 250, 380, 450),
|
||||
(18, "山东", "淄博、枣庄、东营、潍坊、济宁、泰安", "", "", 350, 300, 250, 380, ""),
|
||||
(18, "山东", "其他地区", "", "", 350, 300, 250, 360, ""),
|
||||
(19, "青岛", "青岛", "青岛", "7-9 月", 350, 300, 250, 380, 450),
|
||||
(20, "河南", "郑州", "", "", 380, 350, 300, 380, ""),
|
||||
(20, "河南", "洛阳", "洛阳", "4-5 月上旬", 330, 300, 250, 330, 390),
|
||||
(20, "河南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(21, "湖北", "武汉", "", "", 350, 330, 300, 350, ""),
|
||||
(21, "湖北", "其他地区", "", "", 320, 300, 250, 320, ""),
|
||||
(22, "湖南", "长沙", "", "", 350, 330, 300, 350, ""),
|
||||
(22, "湖南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(23, "广东", "广州", "", "", 450, 400, 400, 450, ""),
|
||||
(23, "广东", "珠海", "", "", 450, 400, 350, 450, ""),
|
||||
(23, "广东", "佛山、东莞、中山、江门", "", "", 350, 300, 250, 450, ""),
|
||||
(23, "广东", "其他地区", "", "", 350, 300, 250, 420, ""),
|
||||
(24, "深圳", "深圳", "", "", 450, 400, 400, 450, ""),
|
||||
(25, "广西", "南宁", "", "", 350, 330, 300, 350, ""),
|
||||
(25, "广西", "桂林、北海", "桂林、北海", "1-2 月、7-9 月", 330, 300, 250, 330, 390),
|
||||
(25, "广西", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(26, "海南", "海口、文昌、澄迈县", "海口、文昌、澄迈县", "11-2 月", 350, 330, 310, 350, 420),
|
||||
(26, "海南", "琼海、万宁、陵水县、保亭县", "琼海、万宁、陵水县、保亭县", "11-3 月", 350, 330, 310, 350, 420),
|
||||
(26, "海南", "三沙、儋州、五指山、东方、安定县、屯昌县、临高县、白沙县、昌江县、乐东县、琼中县、洋浦开发区", "", "", 350, 330, 310, 350, ""),
|
||||
(26, "海南", "三亚", "三亚", "10-4 月", 400, 380, 350, 400, 480),
|
||||
(27, "重庆", "9 个中心城区、北部新区", "", "", 370, 350, 330, 370, ""),
|
||||
(27, "重庆", "其他地区", "", "", 300, 280, 260, 300, ""),
|
||||
(28, "四川", "成都", "", "", 370, 350, 330, 370, ""),
|
||||
(28, "四川", "阿坝州、甘孜州", "", "", 330, 300, 250, 330, ""),
|
||||
(28, "四川", "绵阳、乐山、雅安", "", "", 320, 300, 250, 320, ""),
|
||||
(28, "四川", "宜宾", "", "", 300, 280, 250, 300, ""),
|
||||
(28, "四川", "凉山州", "", "", 330, 300, 250, 330, ""),
|
||||
(28, "四川", "德阳、遂宁、巴中", "", "", 310, 290, 250, 310, ""),
|
||||
(28, "四川", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||
(29, "贵州", "贵阳", "", "", 370, 350, 300, 370, ""),
|
||||
(29, "贵州", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||
(30, "云南", "昆明", "", "", 380, 350, 300, 380, ""),
|
||||
(30, "云南", "大理州、丽江市、迪庆州、西双版纳州", "", "", 350, 300, 250, 380, ""),
|
||||
(30, "云南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(31, "西藏", "拉萨", "拉萨", "5-10 月", 350, 330, 300, 350, 420),
|
||||
(31, "西藏", "其他地区", "其他地区", "5-10 月", 300, 280, 250, 300, 360),
|
||||
(32, "陕西", "西安", "", "", 350, 330, 300, 350, ""),
|
||||
(32, "陕西", "榆林、延安", "", "", 300, 280, 250, 300, ""),
|
||||
(32, "陕西", "杨凌区", "", "", 260, 240, 220, 260, ""),
|
||||
(32, "陕西", "咸阳、宝鸡", "", "", 260, 240, 220, 260, ""),
|
||||
(32, "陕西", "渭南、韩城", "", "", 260, 240, 220, 260, ""),
|
||||
(32, "陕西", "其他地区", "", "", 230, 210, 200, 230, ""),
|
||||
(33, "甘肃", "兰州", "", "", 350, 330, 300, 350, ""),
|
||||
(33, "甘肃", "其他地区", "", "", 310, 290, 250, 310, ""),
|
||||
(34, "青海", "西宁", "西宁", "6-9 月", 350, 330, 300, 350, 420),
|
||||
(34, "青海", "玉树州", "玉树州", "5-9 月", 300, 280, 250, 300, 360),
|
||||
(34, "青海", "果洛州", "", "", 300, 280, 250, 300, ""),
|
||||
(34, "青海", "海北州、黄南州", "海北州、黄南州", "5-9 月", 250, 230, 210, 250, 300),
|
||||
(34, "青海", "海东、海南州", "海东、海南州", "5-9 月", 250, 230, 210, 250, 300),
|
||||
(34, "青海", "海西州", "海西州", "5-9 月", 200, 200, 200, 200, 240),
|
||||
(35, "宁夏", "银川", "", "", 350, 330, 300, 350, ""),
|
||||
(35, "宁夏", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(36, "新疆", "乌鲁木齐", "", "", 350, 330, 300, 350, ""),
|
||||
(36, "新疆", "石河子、克拉玛依、昌吉州、伊犁州、阿勒泰地区、博州、吐鲁番、哈密地区、巴州、和田地区", "", "", 340, 300, 250, 340, ""),
|
||||
(36, "新疆", "克州", "", "", 320, 300, 250, 320, ""),
|
||||
(36, "新疆", "喀什地区", "", "", 300, 280, 250, 300, ""),
|
||||
(36, "新疆", "阿克苏地区", "", "", 300, 280, 250, 300, ""),
|
||||
(36, "新疆", "塔城地区", "", "", 300, 280, 250, 300, ""),
|
||||
(37, "港澳台", "香港、澳门、台湾", "", "", 450, 400, 350, 500, ""),
|
||||
(38, "国外", "国外", "", "", 700, 600, 500, 700, ""),
|
||||
]
|
||||
|
||||
|
||||
def build_workbook() -> Workbook:
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.title = "差旅住宿费标准"
|
||||
headers = [
|
||||
"序号",
|
||||
"地区",
|
||||
"地区(城市)",
|
||||
"旺季地区",
|
||||
"旺季期间",
|
||||
"公司级管理人员、高层经理(P7及以上)",
|
||||
"中层经理、基层经理(P4-P6、外聘专家)",
|
||||
"其他员工",
|
||||
"超标限额",
|
||||
"旺季超标限额",
|
||||
]
|
||||
|
||||
worksheet.append(["差旅住宿费标准"])
|
||||
worksheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
|
||||
worksheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
|
||||
worksheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
|
||||
worksheet["A1"].alignment = Alignment(horizontal="center")
|
||||
worksheet.append(headers)
|
||||
for row in ROWS:
|
||||
worksheet.append(row)
|
||||
|
||||
header_fill = PatternFill("solid", fgColor="D9EAF7")
|
||||
thin = Side(style="thin", color="B7C9D6")
|
||||
for cell in worksheet[2]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for row in worksheet.iter_rows(min_row=3, max_row=worksheet.max_row):
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for cell in row[5:]:
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
worksheet.freeze_panes = "A3"
|
||||
worksheet.auto_filter.ref = f"A2:J{worksheet.max_row}"
|
||||
widths = [8, 12, 42, 28, 28, 22, 26, 12, 12, 14]
|
||||
for index, width in enumerate(widths, start=1):
|
||||
worksheet.column_dimensions[get_column_letter(index)].width = width
|
||||
worksheet.row_dimensions[1].height = 26
|
||||
worksheet.row_dimensions[2].height = 42
|
||||
for index in range(3, worksheet.max_row + 1):
|
||||
worksheet.row_dimensions[index].height = 36
|
||||
|
||||
subsidy_sheet = workbook.create_sheet("出差补助标准")
|
||||
subsidy_headers = [
|
||||
"补助类型",
|
||||
"项目",
|
||||
"港澳台",
|
||||
"直辖市/特区",
|
||||
"西藏",
|
||||
"新疆-乌鲁木齐",
|
||||
"新疆-其他",
|
||||
"其他地区",
|
||||
"国外",
|
||||
]
|
||||
subsidy_rows = [
|
||||
("伙食补助", "自行解决餐食", 75, 65, 65, 55, 55, 55, 140),
|
||||
("基本补助", "基本出差补贴", 35, 35, 105, 75, 135, 35, 35),
|
||||
("合计", "", 110, 100, 170, 130, 190, 90, 175),
|
||||
]
|
||||
subsidy_sheet.append(["出差补助标准"])
|
||||
subsidy_sheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(subsidy_headers))
|
||||
subsidy_sheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
|
||||
subsidy_sheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
|
||||
subsidy_sheet["A1"].alignment = Alignment(horizontal="center")
|
||||
subsidy_sheet.append(subsidy_headers)
|
||||
for row in subsidy_rows:
|
||||
subsidy_sheet.append(row)
|
||||
subsidy_sheet.append(["备注", "注 1:新疆分公司同事出差至乌鲁木齐外的其他新疆地区,基本补助标准为 95 元。"])
|
||||
subsidy_sheet.append(["备注", "注 2:西藏分公司同事出差至拉萨市外的其他西藏地区,基本补助标准为 35 元。"])
|
||||
subsidy_sheet.merge_cells(start_row=6, start_column=2, end_row=6, end_column=len(subsidy_headers))
|
||||
subsidy_sheet.merge_cells(start_row=7, start_column=2, end_row=7, end_column=len(subsidy_headers))
|
||||
for cell in subsidy_sheet[2]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for row in subsidy_sheet.iter_rows(min_row=3, max_row=7):
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for cell in subsidy_sheet[5]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = PatternFill("solid", fgColor="E2F0D9")
|
||||
subsidy_sheet.freeze_panes = "A3"
|
||||
subsidy_sheet.auto_filter.ref = "A2:I5"
|
||||
subsidy_widths = [14, 18, 12, 16, 12, 18, 16, 14, 12]
|
||||
for index, width in enumerate(subsidy_widths, start=1):
|
||||
subsidy_sheet.column_dimensions[get_column_letter(index)].width = width
|
||||
subsidy_sheet.row_dimensions[1].height = 26
|
||||
subsidy_sheet.row_dimensions[2].height = 36
|
||||
for index in range(3, 8):
|
||||
subsidy_sheet.row_dimensions[index].height = 28
|
||||
|
||||
source_sheet = workbook.create_sheet("来源说明")
|
||||
source_sheet.append(["来源文件", "页码", "说明"])
|
||||
source_sheet.append(
|
||||
[
|
||||
"远光软件2026费用报销说明手册.pdf",
|
||||
"第 13-19 页",
|
||||
"依据 PDF 附件 3《差旅住宿费标准》整理为默认支撑表。",
|
||||
]
|
||||
)
|
||||
source_sheet.append(
|
||||
[
|
||||
"远光软件2026费用报销说明手册.pdf",
|
||||
"第 20 页",
|
||||
"依据 PDF 附件 4《出差补助标准》整理为默认支撑表。",
|
||||
]
|
||||
)
|
||||
for row in source_sheet.iter_rows():
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(wrap_text=True, vertical="center")
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for cell in source_sheet[1]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = header_fill
|
||||
source_sheet.column_dimensions["A"].width = 34
|
||||
source_sheet.column_dimensions["B"].width = 14
|
||||
source_sheet.column_dimensions["C"].width = 56
|
||||
return workbook
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
workbook = build_workbook()
|
||||
workbook.save(OUTPUT_PATH)
|
||||
print(OUTPUT_PATH)
|
||||
print(f"rows={len(ROWS)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -240,7 +240,7 @@ run_bootstrap_python() {
|
||||
}
|
||||
|
||||
dependencies_ready() {
|
||||
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
|
||||
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, openpyxl, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
pip_ready() {
|
||||
|
||||
@@ -62,13 +62,39 @@ def get_current_user(
|
||||
)
|
||||
|
||||
|
||||
def require_admin_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
def require_admin_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
if current_user.is_admin or "manager" in current_user.role_codes:
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||
)
|
||||
|
||||
|
||||
def require_rule_editor_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
role_codes = {item.strip() for item in current_user.role_codes}
|
||||
if current_user.is_admin or "manager" in role_codes or "finance" in role_codes:
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有财务人员或高级管理人员可以编辑规则草稿。",
|
||||
)
|
||||
|
||||
|
||||
def require_rule_reviewer_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
role_codes = {item.strip() for item in current_user.role_codes}
|
||||
if current_user.is_admin or "manager" in role_codes:
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有高级管理人员可以审核、发布或恢复正式规则。",
|
||||
)
|
||||
|
||||
@@ -50,6 +50,16 @@ class AgentRunRepository:
|
||||
self.db.refresh(tool_call)
|
||||
return tool_call
|
||||
|
||||
def get_tool_call(self, tool_call_id: str) -> AgentToolCall | None:
|
||||
stmt = select(AgentToolCall).where(AgentToolCall.id == tool_call_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def save_tool_call(self, tool_call: AgentToolCall) -> AgentToolCall:
|
||||
self.db.add(tool_call)
|
||||
self.db.commit()
|
||||
self.db.refresh(tool_call)
|
||||
return tool_call
|
||||
|
||||
def create_semantic_parse(self, semantic_parse: SemanticParseLog) -> SemanticParseLog:
|
||||
self.db.add(semantic_parse)
|
||||
self.db.commit()
|
||||
|
||||
478
server/src/app/services/agent_asset_spreadsheet.py
Normal file
478
server/src/app/services/agent_asset_spreadsheet.py
Normal file
@@ -0,0 +1,478 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.core.config import SERVER_DIR, get_settings
|
||||
|
||||
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
||||
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
FINANCE_RULES_LIBRARY = "finance-rules"
|
||||
RISK_RULES_LIBRARY = "risk-rules"
|
||||
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
||||
SPREADSHEET_MIME_TYPE = (
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuleSpreadsheetMeta:
|
||||
file_name: str
|
||||
storage_key: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
checksum: str
|
||||
updated_at: str
|
||||
updated_by: str
|
||||
source: str = "upload"
|
||||
|
||||
|
||||
class AgentAssetSpreadsheetManager:
|
||||
def __init__(
|
||||
self,
|
||||
storage_root: Path | None = None,
|
||||
rule_root: Path | None = None,
|
||||
) -> None:
|
||||
settings = get_settings()
|
||||
self.storage_root = Path(storage_root or settings.resolved_storage_root_dir).resolve()
|
||||
self.asset_root = (self.storage_root / "agent_assets").resolve()
|
||||
self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve()
|
||||
|
||||
def ensure_rule_library_dirs(self) -> None:
|
||||
for library in sorted(RULE_LIBRARY_NAMES):
|
||||
(self.rule_root / library).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def store_spreadsheet(
|
||||
self,
|
||||
*,
|
||||
asset_id: str,
|
||||
version: str,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
actor_name: str,
|
||||
source: str = "upload",
|
||||
) -> RuleSpreadsheetMeta:
|
||||
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("规则表文件名不能为空。")
|
||||
if not content:
|
||||
raise ValueError("规则表文件内容不能为空。")
|
||||
|
||||
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name
|
||||
target_path = (self.storage_root / relative_path).resolve()
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(content)
|
||||
|
||||
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
|
||||
return RuleSpreadsheetMeta(
|
||||
file_name=normalized_name,
|
||||
storage_key=relative_path.as_posix(),
|
||||
mime_type=mime_type,
|
||||
size_bytes=len(content),
|
||||
checksum=hashlib.sha256(content).hexdigest(),
|
||||
updated_at=datetime.now(UTC).isoformat(),
|
||||
updated_by=str(actor_name or "system").strip() or "system",
|
||||
source=source,
|
||||
)
|
||||
|
||||
def store_rule_library_spreadsheet(
|
||||
self,
|
||||
*,
|
||||
library: str,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
actor_name: str,
|
||||
source: str = "rule-library",
|
||||
) -> RuleSpreadsheetMeta:
|
||||
normalized_library = str(library or "").strip()
|
||||
if normalized_library not in RULE_LIBRARY_NAMES:
|
||||
raise ValueError("规则库目录不合法。")
|
||||
|
||||
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("规则表文件名不能为空。")
|
||||
if not content:
|
||||
raise ValueError("规则表文件内容不能为空。")
|
||||
|
||||
self.ensure_rule_library_dirs()
|
||||
relative_path = Path("rules") / normalized_library / normalized_name
|
||||
target_path = (SERVER_DIR / relative_path).resolve()
|
||||
try:
|
||||
target_path.relative_to(self.rule_root)
|
||||
except ValueError:
|
||||
raise ValueError("规则库文件路径不合法。")
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(content)
|
||||
|
||||
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
|
||||
return RuleSpreadsheetMeta(
|
||||
file_name=normalized_name,
|
||||
storage_key=relative_path.as_posix(),
|
||||
mime_type=mime_type,
|
||||
size_bytes=len(content),
|
||||
checksum=hashlib.sha256(content).hexdigest(),
|
||||
updated_at=datetime.now(UTC).isoformat(),
|
||||
updated_by=str(actor_name or "system").strip() or "system",
|
||||
source=source,
|
||||
)
|
||||
|
||||
def resolve_storage_path(self, storage_key: str) -> Path:
|
||||
normalized = Path(str(storage_key or "").strip())
|
||||
if not normalized.parts:
|
||||
raise FileNotFoundError("规则表文件不存在。")
|
||||
|
||||
if normalized.parts[0] == "rules":
|
||||
resolved = (SERVER_DIR / normalized).resolve()
|
||||
allowed_root = self.rule_root
|
||||
else:
|
||||
resolved = (self.storage_root / normalized).resolve()
|
||||
allowed_root = self.storage_root
|
||||
|
||||
try:
|
||||
resolved.relative_to(allowed_root)
|
||||
except ValueError:
|
||||
raise FileNotFoundError("规则表文件不存在。")
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def parse_version_markdown(markdown: str) -> RuleSpreadsheetMeta | None:
|
||||
match = RULE_SPREADSHEET_BLOCK_PATTERN.search(str(markdown or ""))
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
return RuleSpreadsheetMeta(
|
||||
file_name=str(payload.get("file_name") or "").strip(),
|
||||
storage_key=str(payload.get("storage_key") or "").strip(),
|
||||
mime_type=str(payload.get("mime_type") or SPREADSHEET_MIME_TYPE).strip()
|
||||
or SPREADSHEET_MIME_TYPE,
|
||||
size_bytes=int(payload.get("size_bytes") or 0),
|
||||
checksum=str(payload.get("checksum") or "").strip(),
|
||||
updated_at=str(payload.get("updated_at") or "").strip(),
|
||||
updated_by=str(payload.get("updated_by") or "system").strip() or "system",
|
||||
source=str(payload.get("source") or "upload").strip() or "upload",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_version_markdown(
|
||||
*,
|
||||
rule_name: str,
|
||||
version: str,
|
||||
metadata: RuleSpreadsheetMeta,
|
||||
) -> str:
|
||||
sections = [
|
||||
f"# {rule_name}",
|
||||
"",
|
||||
"## 规则载体",
|
||||
"",
|
||||
"- 详情类型:Excel 表格",
|
||||
f"- 当前规则版本:`{version}`",
|
||||
f"- 表格文件:`{metadata.file_name}`",
|
||||
f"- 最近更新人:{metadata.updated_by}",
|
||||
f"- 最近更新时间:{metadata.updated_at}",
|
||||
"",
|
||||
"## 使用说明",
|
||||
"",
|
||||
"- 管理员可直接在规则中心内联编辑 Excel 表格,并通过 ONLYOFFICE 回写新版本。",
|
||||
"- 上传新的 Excel 文件后,会自动生成新的规则版本快照。",
|
||||
"- 切换到历史版本时仅提供预览,不允许直接覆盖历史快照。",
|
||||
"",
|
||||
"```rule-spreadsheet",
|
||||
json.dumps(asdict(metadata), ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
]
|
||||
return "\n".join(sections)
|
||||
|
||||
@staticmethod
|
||||
def build_rule_document_config(
|
||||
metadata: RuleSpreadsheetMeta,
|
||||
*,
|
||||
asset_version: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"kind": "spreadsheet",
|
||||
"file_name": metadata.file_name,
|
||||
"mime_type": metadata.mime_type,
|
||||
"size_bytes": metadata.size_bytes,
|
||||
"checksum": metadata.checksum,
|
||||
"updated_at": metadata.updated_at,
|
||||
"updated_by": metadata.updated_by,
|
||||
"source": metadata.source,
|
||||
"asset_version": asset_version,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_company_travel_rule_template() -> bytes:
|
||||
standard_rows = [
|
||||
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
||||
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"],
|
||||
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"],
|
||||
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"],
|
||||
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"],
|
||||
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"],
|
||||
]
|
||||
instruction_rows = [
|
||||
["字段", "填写说明"],
|
||||
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
|
||||
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
|
||||
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
|
||||
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
|
||||
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
|
||||
["备注", "记录豁免条件、灰度口径或制度来源。"],
|
||||
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
|
||||
]
|
||||
return _build_xlsx_bytes(
|
||||
[
|
||||
("差旅报销标准", standard_rows),
|
||||
("填表说明", instruction_rows),
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
|
||||
return _build_xlsx_bytes([(sheet_name, [[""]])])
|
||||
|
||||
@staticmethod
|
||||
def rebuild_from_uploaded_content(content: bytes) -> bytes:
|
||||
if not content:
|
||||
raise ValueError("待导入的表格内容不能为空。")
|
||||
|
||||
try:
|
||||
workbook = load_workbook(
|
||||
filename=BytesIO(content),
|
||||
read_only=True,
|
||||
data_only=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError("无法解析上传的 Excel 表格。") from exc
|
||||
|
||||
sheets: list[tuple[str, list[list[object]]]] = []
|
||||
for worksheet in workbook.worksheets:
|
||||
rows = [
|
||||
list(row)
|
||||
for row in worksheet.iter_rows(values_only=True)
|
||||
]
|
||||
sheets.append((worksheet.title, _trim_empty_table(rows)))
|
||||
|
||||
if not sheets:
|
||||
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
|
||||
return _build_xlsx_bytes(sheets)
|
||||
|
||||
|
||||
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
||||
created_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
workbook_buffer = BytesIO()
|
||||
|
||||
with ZipFile(workbook_buffer, "w", ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", _build_content_types_xml(sheets))
|
||||
archive.writestr("_rels/.rels", _build_root_rels_xml())
|
||||
archive.writestr("docProps/app.xml", _build_app_xml(sheets))
|
||||
archive.writestr("docProps/core.xml", _build_core_xml(created_at))
|
||||
archive.writestr("xl/workbook.xml", _build_workbook_xml(sheets))
|
||||
archive.writestr("xl/_rels/workbook.xml.rels", _build_workbook_rels_xml(sheets))
|
||||
archive.writestr("xl/styles.xml", _build_styles_xml())
|
||||
|
||||
for index, (_, rows) in enumerate(sheets, start=1):
|
||||
archive.writestr(
|
||||
f"xl/worksheets/sheet{index}.xml",
|
||||
_build_sheet_xml(rows),
|
||||
)
|
||||
|
||||
return workbook_buffer.getvalue()
|
||||
|
||||
|
||||
def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
overrides = [
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
|
||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
|
||||
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
|
||||
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
|
||||
]
|
||||
overrides.extend(
|
||||
[
|
||||
f'<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
for index, _ in enumerate(sheets, start=1)
|
||||
]
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
f'{"".join(overrides)}'
|
||||
"</Types>"
|
||||
)
|
||||
|
||||
|
||||
def _build_root_rels_xml() -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
|
||||
"</Relationships>"
|
||||
)
|
||||
|
||||
|
||||
def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
titles = "".join(
|
||||
[f'<vt:lpstr>{escape(name)}</vt:lpstr>' for name, _ in sheets]
|
||||
)
|
||||
sheet_count = len(sheets)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
|
||||
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
||||
'<Application>Microsoft Excel</Application>'
|
||||
f"<HeadingPairs><vt:vector size=\"2\" baseType=\"variant\"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant></vt:vector></HeadingPairs>"
|
||||
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>"
|
||||
"</Properties>"
|
||||
)
|
||||
|
||||
|
||||
def _build_core_xml(created_at: str) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
||||
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
||||
'xmlns:dcterms="http://purl.org/dc/terms/" '
|
||||
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||||
"<dc:creator>X-Financial</dc:creator>"
|
||||
"<cp:lastModifiedBy>X-Financial</cp:lastModifiedBy>"
|
||||
f'<dcterms:created xsi:type="dcterms:W3CDTF">{created_at}</dcterms:created>'
|
||||
f'<dcterms:modified xsi:type="dcterms:W3CDTF">{created_at}</dcterms:modified>'
|
||||
"</cp:coreProperties>"
|
||||
)
|
||||
|
||||
|
||||
def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
sheet_items = "".join(
|
||||
[
|
||||
f'<sheet name="{escape(name)}" sheetId="{index}" r:id="rId{index}"/>'
|
||||
for index, (name, _) in enumerate(sheets, start=1)
|
||||
]
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
"<bookViews><workbookView/></bookViews>"
|
||||
f"<sheets>{sheet_items}</sheets>"
|
||||
"</workbook>"
|
||||
)
|
||||
|
||||
|
||||
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
relationships = "".join(
|
||||
[
|
||||
f'<Relationship Id="rId{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
|
||||
for index, _ in enumerate(sheets, start=1)
|
||||
]
|
||||
)
|
||||
relationships += (
|
||||
f'<Relationship Id="rId{len(sheets) + 1}" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
|
||||
'Target="styles.xml"/>'
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
f"{relationships}"
|
||||
"</Relationships>"
|
||||
)
|
||||
|
||||
|
||||
def _build_styles_xml() -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||||
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||||
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
|
||||
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
||||
'</styleSheet>'
|
||||
)
|
||||
|
||||
|
||||
def _build_sheet_xml(rows: list[list[object]]) -> str:
|
||||
normalized_rows = rows or [[""]]
|
||||
max_column_count = max((len(row) for row in normalized_rows), default=1)
|
||||
worksheet_rows: list[str] = []
|
||||
|
||||
for row_index, row in enumerate(normalized_rows, start=1):
|
||||
cells: list[str] = []
|
||||
for column_index, cell in enumerate(row, start=1):
|
||||
ref = f"{_column_letter(column_index)}{row_index}"
|
||||
text = "" if cell is None else str(cell)
|
||||
preserve = ' xml:space="preserve"' if text.strip() != text or "\n" in text else ""
|
||||
cells.append(
|
||||
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>'
|
||||
)
|
||||
worksheet_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
|
||||
|
||||
dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
f'<dimension ref="{dimension}"/>'
|
||||
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>"
|
||||
"<sheetFormatPr defaultRowHeight=\"18\"/>"
|
||||
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
|
||||
"</worksheet>"
|
||||
)
|
||||
|
||||
|
||||
def _column_letter(index: int) -> str:
|
||||
value = max(1, int(index))
|
||||
result = ""
|
||||
while value > 0:
|
||||
value, remainder = divmod(value - 1, 26)
|
||||
result = f"{chr(65 + remainder)}{result}"
|
||||
return result
|
||||
|
||||
|
||||
def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
|
||||
normalized_rows = [list(row) for row in rows]
|
||||
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):
|
||||
normalized_rows.pop()
|
||||
|
||||
if not normalized_rows:
|
||||
return [[""]]
|
||||
|
||||
max_column = 0
|
||||
for row in normalized_rows:
|
||||
for index, cell in enumerate(row, start=1):
|
||||
if cell not in (None, ""):
|
||||
max_column = max(max_column, index)
|
||||
|
||||
if max_column <= 0:
|
||||
return [[""]]
|
||||
|
||||
return [row[:max_column] for row in normalized_rows]
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import AgentPermissionLevel, AgentRunStatus
|
||||
from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunStatus
|
||||
from app.core.logging import get_logger
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
||||
from app.repositories.agent_run import AgentRunRepository
|
||||
@@ -15,6 +15,8 @@ from app.services.agent_foundation import AgentFoundationService
|
||||
|
||||
logger = get_logger("app.services.agent_runs")
|
||||
|
||||
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
|
||||
|
||||
|
||||
class AgentRunService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
@@ -30,11 +32,13 @@ class AgentRunService:
|
||||
limit: int = 20,
|
||||
) -> list[AgentRunRead]:
|
||||
self._ensure_ready()
|
||||
self._reconcile_stale_knowledge_index_runs()
|
||||
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
|
||||
return [self._serialize_run(item) for item in runs]
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRunRead | None:
|
||||
self._ensure_ready()
|
||||
self._reconcile_stale_knowledge_index_runs(target_run_id=run_id)
|
||||
run = self.repository.get_by_run_id(run_id)
|
||||
if run is None:
|
||||
return None
|
||||
@@ -174,6 +178,35 @@ class AgentRunService:
|
||||
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
|
||||
return AgentToolCallRead.model_validate(created)
|
||||
|
||||
def update_tool_call(
|
||||
self,
|
||||
tool_call_id: str,
|
||||
*,
|
||||
request_json: dict[str, Any] | None = None,
|
||||
response_json: dict[str, Any] | None = None,
|
||||
status: str | None = None,
|
||||
duration_ms: int | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> AgentToolCallRead:
|
||||
self._ensure_ready()
|
||||
tool_call = self.repository.get_tool_call(tool_call_id)
|
||||
if tool_call is None:
|
||||
raise LookupError("Tool call not found")
|
||||
|
||||
if request_json is not None:
|
||||
tool_call.request_json = request_json
|
||||
if response_json is not None:
|
||||
tool_call.response_json = response_json
|
||||
if status is not None:
|
||||
tool_call.status = status
|
||||
if duration_ms is not None:
|
||||
tool_call.duration_ms = duration_ms
|
||||
tool_call.error_message = error_message
|
||||
|
||||
updated = self.repository.save_tool_call(tool_call)
|
||||
logger.info("Updated tool call id=%s status=%s", updated.id, updated.status)
|
||||
return AgentToolCallRead.model_validate(updated)
|
||||
|
||||
def record_semantic_parse(
|
||||
self,
|
||||
*,
|
||||
@@ -214,6 +247,73 @@ class AgentRunService:
|
||||
def _ensure_ready(self) -> None:
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
|
||||
def _reconcile_stale_knowledge_index_runs(self, *, target_run_id: str | None = None) -> None:
|
||||
runs = self.repository.list(
|
||||
agent=AgentName.HERMES.value,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
limit=200,
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for run in runs:
|
||||
if target_run_id is not None and run.run_id != target_run_id:
|
||||
continue
|
||||
|
||||
route_json = dict(run.route_json or {})
|
||||
if str(route_json.get("job_type") or "").strip() != "knowledge_index_sync":
|
||||
continue
|
||||
|
||||
heartbeat_at = self._parse_heartbeat_time(
|
||||
str(route_json.get("heartbeat_at") or "").strip()
|
||||
)
|
||||
last_seen_at = heartbeat_at or run.started_at
|
||||
if last_seen_at.tzinfo is None:
|
||||
last_seen_at = last_seen_at.replace(tzinfo=UTC)
|
||||
|
||||
if now - last_seen_at <= KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT:
|
||||
continue
|
||||
|
||||
stale_document_ids = [
|
||||
str(document_id).strip()
|
||||
for document_id in list(route_json.get("requested_document_ids") or [])
|
||||
if str(document_id).strip()
|
||||
]
|
||||
if stale_document_ids:
|
||||
from app.services.knowledge import (
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
KnowledgeService,
|
||||
)
|
||||
|
||||
KnowledgeService(db=self.db).set_document_ingest_statuses(
|
||||
stale_document_ids,
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
agent_run_id=run.run_id,
|
||||
)
|
||||
|
||||
route_json.update(
|
||||
{
|
||||
"phase": "stale_failed",
|
||||
"heartbeat_at": now.isoformat(),
|
||||
}
|
||||
)
|
||||
run.route_json = route_json
|
||||
run.status = AgentRunStatus.FAILED.value
|
||||
run.result_summary = "知识归纳任务长时间无心跳,系统已自动标记失败。"
|
||||
run.error_message = "Knowledge index heartbeat timed out."
|
||||
run.finished_at = now
|
||||
self.repository.save_run(run)
|
||||
logger.warning("Marked stale knowledge index run as failed run_id=%s", run.run_id)
|
||||
|
||||
@staticmethod
|
||||
def _parse_heartbeat_time(raw_value: str) -> datetime | None:
|
||||
normalized = str(raw_value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_run(run: AgentRun) -> AgentRunRead:
|
||||
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None
|
||||
|
||||
@@ -598,13 +598,13 @@ class ExpenseRuleRuntimeService:
|
||||
return catalog
|
||||
|
||||
def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None:
|
||||
current_version = str(asset.current_version or "").strip()
|
||||
if not current_version:
|
||||
published_version = str(asset.published_version or asset.current_version or "").strip()
|
||||
if not published_version:
|
||||
return None
|
||||
return self.db.scalar(
|
||||
select(AgentAssetVersion).where(
|
||||
AgentAssetVersion.asset_id == asset.id,
|
||||
AgentAssetVersion.version == current_version,
|
||||
AgentAssetVersion.version == published_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Requires-Dist: pydantic-settings<3.0.0,>=2.6.0
|
||||
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
|
||||
Requires-Dist: email-validator<3.0.0,>=2.2.0
|
||||
Requires-Dist: python-multipart<1.0.0,>=0.0.20
|
||||
Requires-Dist: openpyxl<4.0.0,>=3.1.5
|
||||
Requires-Dist: lightrag-hku<1.5.0,>=1.4.16
|
||||
Requires-Dist: qdrant-client<2.0.0,>=1.18.0
|
||||
Provides-Extra: dev
|
||||
|
||||
@@ -76,6 +76,7 @@ src/app/schemas/settings.py
|
||||
src/app/schemas/system_log.py
|
||||
src/app/schemas/user_agent.py
|
||||
src/app/services/__init__.py
|
||||
src/app/services/agent_asset_spreadsheet.py
|
||||
src/app/services/agent_assets.py
|
||||
src/app/services/agent_conversations.py
|
||||
src/app/services/agent_foundation.py
|
||||
@@ -90,7 +91,10 @@ src/app/services/expense_rule_runtime.py
|
||||
src/app/services/hermes_sync.py
|
||||
src/app/services/knowledge.py
|
||||
src/app/services/knowledge_index_tasks.py
|
||||
src/app/services/knowledge_normalizer.py
|
||||
src/app/services/knowledge_rag.py
|
||||
src/app/services/knowledge_scheduler.py
|
||||
src/app/services/knowledge_sync.py
|
||||
src/app/services/model_connectivity.py
|
||||
src/app/services/ocr.py
|
||||
src/app/services/ontology.py
|
||||
@@ -106,8 +110,11 @@ src/x_financial_server.egg-info/SOURCES.txt
|
||||
src/x_financial_server.egg-info/dependency_links.txt
|
||||
src/x_financial_server.egg-info/requires.txt
|
||||
src/x_financial_server.egg-info/top_level.txt
|
||||
tests/test_agent_asset_onlyoffice_key.py
|
||||
tests/test_agent_asset_service.py
|
||||
tests/test_agent_asset_spreadsheet_import.py
|
||||
tests/test_agent_foundation_endpoints.py
|
||||
tests/test_agent_runs_service.py
|
||||
tests/test_auth_service.py
|
||||
tests/test_config_settings_reload.py
|
||||
tests/test_document_intelligence.py
|
||||
@@ -115,12 +122,16 @@ tests/test_employee_service.py
|
||||
tests/test_env_file_precedence.py
|
||||
tests/test_expense_claim_service.py
|
||||
tests/test_imports.py
|
||||
tests/test_knowledge_normalizer.py
|
||||
tests/test_knowledge_onlyoffice_config.py
|
||||
tests/test_knowledge_rag_service.py
|
||||
tests/test_knowledge_service.py
|
||||
tests/test_ocr_endpoints.py
|
||||
tests/test_ocr_service.py
|
||||
tests/test_ontology_service.py
|
||||
tests/test_openapi_schema.py
|
||||
tests/test_reimbursement_endpoints.py
|
||||
tests/test_runtime_chat_service.py
|
||||
tests/test_server_start_dependencies.py
|
||||
tests/test_settings_persistence.py
|
||||
tests/test_settings_service.py
|
||||
|
||||
@@ -8,6 +8,7 @@ pydantic-settings<3.0.0,>=2.6.0
|
||||
python-dotenv<2.0.0,>=1.0.1
|
||||
email-validator<3.0.0,>=2.2.0
|
||||
python-multipart<1.0.0,>=0.0.20
|
||||
openpyxl<4.0.0,>=3.1.5
|
||||
lightrag-hku<1.5.0,>=1.4.16
|
||||
qdrant-client<2.0.0,>=1.18.0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user