diff --git a/requirements.txt b/requirements.txt
index b361711..0e810ac 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
+pytest
openpyxl~=3.1.5
pyside6~=6.9.0
python-docx~=1.1.2
diff --git a/toolbox/config/achievement.default.excel.json b/toolbox/config/achievement.default.excel.json
new file mode 100644
index 0000000..9578219
--- /dev/null
+++ b/toolbox/config/achievement.default.excel.json
@@ -0,0 +1,109 @@
+{
+ "name": "achievement",
+ "version": "9.0",
+ "compatibleVersion": [
+ "9.0"
+ ],
+ "config": [
+ {
+ "name": "version",
+ "position": "H1",
+ "type": "single"
+ },
+ {
+ "name": "class_full_name",
+ "position": "D10",
+ "type": "single"
+ },
+ {
+ "name": "course_name",
+ "position": "D5",
+ "type": "single"
+ },
+ {
+ "name": "teacher_name",
+ "position": "D7",
+ "type": "single"
+ },
+ {
+ "name": "master_name",
+ "position": "D8",
+ "type": "single"
+ },
+ {
+ "name": "class_single_name",
+ "position": "K",
+ "type": "range",
+ "start": 2,
+ "end": 5
+ },
+ {
+ "name": "class_single_number",
+ "position": "M",
+ "type": "range",
+ "start": 2,
+ "end": 5
+ },
+ {
+ "name": "kpi_number",
+ "position": "H8",
+ "type": "single"
+ },
+ {
+ "name": "hml",
+ "position": "H",
+ "type": "range",
+ "start": 22,
+ "end": null
+ },
+ {
+ "name": "hml_goal",
+ "position": "I",
+ "type": "range",
+ "start": 22,
+ "end": null
+ },
+ {
+ "name": "hml_indicate",
+ "position": "Q",
+ "type": "range",
+ "start": 22,
+ "end": null
+ },
+ {
+ }
+ ]
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/toolbox/config/override.excel.json b/toolbox/config/override.excel.json
new file mode 100644
index 0000000..e69de29
diff --git a/toolbox/models/config.py b/toolbox/models/config.py
new file mode 100644
index 0000000..f65f52b
--- /dev/null
+++ b/toolbox/models/config.py
@@ -0,0 +1,112 @@
+# Copyright (c) 2025 Jeffrey Hsu - JITToolBox
+# #
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# #
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+# #
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+import json
+from abc import ABC, abstractmethod
+from os import PathLike
+from typing import Optional
+
+
+class BaseExcelConfig(ABC):
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ ...
+
+ @property
+ @abstractmethod
+ def position(self) -> str:
+ ...
+
+
+class RangeExcelConfigMixin(ABC):
+ @property
+ @abstractmethod
+ def start(self) -> int:
+ ...
+
+ @property
+ @abstractmethod
+ def end(self) -> Optional[int]:
+ ...
+
+ @property
+ @abstractmethod
+ def fposition(self) -> str:
+ ...
+
+
+class SingleExcelConfigItem(BaseExcelConfig):
+
+ def __init__(self, name: str, position: str):
+ self._name = name
+ self._position = position
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def position(self) -> str:
+ return self._position
+
+
+class RangeExcelConfigItem(SingleExcelConfigItem, RangeExcelConfigMixin):
+
+ def __init__(self, name: str, position: str, start: int, end: Optional[int] = None):
+ super().__init__(name, position)
+ self._start = start
+ self._end = end
+
+ @property
+ def start(self) -> int:
+ return self._start
+
+ @property
+ def end(self) -> Optional[int]:
+ return self._end
+
+ @property
+ def fposition(self) -> str:
+ return self.position + '{}'
+
+
+class AESConfig:
+ _config_list: list[SingleExcelConfigItem | RangeExcelConfigItem]
+
+ def __init__(self, file_path: str | PathLike):
+ self._file_path = file_path
+ self._config_list = []
+ self._init_config()
+
+ def __getitem__(self, item: str):
+ return self.get_config(item)
+
+ def _init_config(self):
+ with open(self._file_path, 'r', encoding='utf-8') as f:
+ config = json.load(f)
+ f.close()
+
+ for item in config['config']:
+ itype = item.pop('type', None)
+ if itype == 'range':
+ self._config_list.append(RangeExcelConfigItem(**item))
+ elif itype == 'single':
+ self._config_list.append(SingleExcelConfigItem(**item))
+
+ def get_config(self, name: str) -> Optional[RangeExcelConfigItem | SingleExcelConfigItem]:
+ for config in self._config_list:
+ if config.name == name:
+ return config
+ return None
diff --git a/toolbox/models/data_model.py b/toolbox/models/data_model.py
new file mode 100644
index 0000000..b014ecf
--- /dev/null
+++ b/toolbox/models/data_model.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2025 Jeffrey Hsu - JITToolBox
+# #
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# #
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+# #
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+from dataclasses import dataclass
+
+
+@dataclass
+class ClassInfo:
+ full_name = ""
+
+ class_name: str
+ class_number: int
+
+
+@dataclass
+class CourseInfo:
+ course_name: str
+ # 任课教师
+ course_teacher_name: str
+ # 课程负责人
+ course_master_name: str
diff --git a/toolbox/services/excel_service.py b/toolbox/services/excel_service.py
new file mode 100644
index 0000000..314671e
--- /dev/null
+++ b/toolbox/services/excel_service.py
@@ -0,0 +1,119 @@
+# Copyright (c) 2025 Jeffrey Hsu - JITToolBox
+# #
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# #
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+# #
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+import json
+from abc import ABC, abstractmethod
+from os import PathLike
+from typing import Optional
+
+import openpyxl
+from openpyxl.workbook import Workbook
+from openpyxl.worksheet.worksheet import Worksheet
+
+from toolbox.models.data_model import ClassInfo
+from toolbox.models.config import AESConfig
+
+
+class BaseExcelService(ABC):
+ _file_path: str | PathLike
+ _workbook: Optional[Workbook]
+ _sheet: None
+
+ @abstractmethod
+ def open(self, *args, **kwargs) -> 'BaseExcelService':
+ ...
+
+ @abstractmethod
+ def save(self) -> 'BaseExcelService':
+ ...
+
+ @abstractmethod
+ def close(self) -> None:
+ ...
+
+ @abstractmethod
+ def active_sheet(self, sheet_name: str) -> 'BaseExcelService':
+ ...
+
+ @property
+ @abstractmethod
+ def cur_active_sheet(self) -> Worksheet:
+ ...
+
+
+class ExcelService(BaseExcelService):
+ def __init__(self, file_path: str | PathLike):
+ self._file_path = file_path
+ self._workbook = None
+ self._sheet = None
+
+ def open(self, *args, **kwargs):
+ self._workbook = openpyxl.load_workbook(self._file_path, *args, **kwargs)
+ return self
+
+ def save(self):
+ self._workbook.save(self._file_path)
+ return self
+
+ def close(self):
+ self._workbook.close()
+
+ def active_sheet(self, sheet_name: str):
+ self._sheet = self._workbook[sheet_name]
+ return self
+
+ @property
+ def cur_active_sheet(self):
+ return self._sheet
+
+ def load_value(self, cell: str):
+ if self._sheet is None:
+ raise ValueError("No active sheet. Please set an active sheet first.")
+ return self._sheet[cell].value
+
+
+class AchievementExcelService(ExcelService):
+ version = ''
+ config = {}
+
+ def __init__(self, file_path: str | PathLike):
+ super().__init__(file_path)
+ self.open(read_only=True, data_only=True)
+
+ def load_config(self, config_path: str | PathLike):
+ self.config = AESConfig(config_path)
+
+ def read_class_info(self) -> list[ClassInfo]:
+ lst = []
+ self.active_sheet('初始录入')
+ full_name = self.load_value(self.config['class_full_name'].position)
+
+ for i in range(self.config['class_single_name'].start, self.config['class_single_name'].end):
+ name = self.load_value(self.config['class_single_name'].fposition.format(i))
+ number = self.load_value(self.config['class_single_number'].fposition.format(i))
+
+ if name is None or number is None:
+ break
+
+ ci = ClassInfo(name, number)
+ ci.full_name = full_name
+ lst.append(ci)
+
+ if len(lst) == 0:
+ raise ValueError("No class information found in the Excel file.")
+
+ return lst
+
+ def read_course_info(self):
+ ...
diff --git a/toolbox/tests/__init__.py b/toolbox/tests/__init__.py
new file mode 100644
index 0000000..7ec691b
--- /dev/null
+++ b/toolbox/tests/__init__.py
@@ -0,0 +1,19 @@
+# Copyright (c) 2025 Jeffrey Hsu - JITToolBox
+# #
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# #
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+# #
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+import os
+from pathlib import Path
+
+PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
+TEST_FILE_PATH = Path(os.path.join(PACKAGE_DIR, 'files'))
diff --git a/toolbox/tests/test_config_model.py b/toolbox/tests/test_config_model.py
new file mode 100644
index 0000000..68f7d7f
--- /dev/null
+++ b/toolbox/tests/test_config_model.py
@@ -0,0 +1,41 @@
+# Copyright (c) 2025 Jeffrey Hsu - JITToolBox
+# #
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# #
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+# #
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+from toolbox.models.config import AESConfig, SingleExcelConfigItem, RangeExcelConfigItem
+from toolbox.tests import TEST_FILE_PATH
+
+
+def test_config_model():
+ aesc = AESConfig(TEST_FILE_PATH / 'test_config_model_01.json')
+
+ a = aesc.get_config('A')
+ assert isinstance(a, SingleExcelConfigItem)
+ assert a.position == 'H1'
+
+ b = aesc.get_config('B')
+ assert isinstance(b, SingleExcelConfigItem)
+ assert b.position == 'D10'
+
+ c = aesc.get_config('C')
+ assert isinstance(c, RangeExcelConfigItem)
+ assert c.position == 'K'
+ assert c.fposition.format(1) == 'K1'
+ assert c.start == 2
+ assert c.end == 5
+
+ d = aesc.get_config('D')
+ assert isinstance(d, RangeExcelConfigItem)
+ assert d.position == 'Q'
+ assert d.fposition.format(d.start) == 'Q22'
+ assert d.end is None
diff --git a/toolbox/tests/test_excel_services.py b/toolbox/tests/test_excel_services.py
new file mode 100644
index 0000000..5a6993d
--- /dev/null
+++ b/toolbox/tests/test_excel_services.py
@@ -0,0 +1,79 @@
+# Copyright (c) 2025 Jeffrey Hsu - JITToolBox
+# #
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# #
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+# #
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+import shutil
+from pathlib import Path
+
+import pytest
+from openpyxl.workbook import Workbook
+
+from toolbox.services.excel_service import ExcelService, AchievementExcelService
+from toolbox.tests import TEST_FILE_PATH
+
+SAVE_TEMP_FILE = False
+
+
+class TestExcelService:
+ es = ExcelService(TEST_FILE_PATH / 'test_excel_services_01.xlsx')
+ es.open(data_only=True)
+
+ def test_open(self):
+ assert isinstance(self.es._workbook, Workbook)
+ assert self.es._sheet is None
+
+ def test_open_failed(self):
+ with pytest.raises(FileNotFoundError):
+ ExcelService(TEST_FILE_PATH / 'non_existent_file.xlsx').open()
+
+ def test_active_sheet(self):
+ self.es.active_sheet('Sheet1')
+ assert self.es._sheet.title == 'Sheet1'
+ self.es.active_sheet('Sheet2')
+ assert self.es._sheet.title == 'Sheet2'
+
+ def test_cur_active_sheet(self):
+ self.es.active_sheet('Sheet1')
+ assert self.es.cur_active_sheet.title == 'Sheet1'
+ self.es.active_sheet('Sheet2')
+ assert self.es.cur_active_sheet.title == 'Sheet2'
+
+ def test_save_and_close(self):
+ temp_excel_file = TEST_FILE_PATH / 'test_excel_services_01_temp.xlsx'
+ shutil.copy(self.es._file_path, temp_excel_file)
+
+ self.es.active_sheet('Sheet1').cur_active_sheet['A1'] = 'Modified'
+ self.es.save().close()
+
+ es2 = ExcelService(temp_excel_file).open().active_sheet('Sheet1')
+ assert es2.cur_active_sheet['A1'].value == 'Modified'
+ es2.close()
+
+ if not SAVE_TEMP_FILE:
+ Path(TEST_FILE_PATH / 'test_excel_services_01_temp.xlsx').unlink()
+
+
+class TestAchievementExcelService:
+ aes = AchievementExcelService(TEST_FILE_PATH / 'test_achievement_excel_service_01.xlsm')
+ aes.load_config(TEST_FILE_PATH / 'test_achievement.default.excel_01.json')
+
+ def test_read_class_info(self):
+ cis = self.aes.read_class_info()
+ assert len(cis) == 2
+ assert cis[0].full_name == '22工程管理(1)(2)'
+ assert cis[1].full_name == '22工程管理(1)(2)'
+
+ assert cis[0].class_name == '22工程管理(1)'
+ assert cis[0].class_number == 34
+ assert cis[1].class_name == '22工程管理(2)'
+ assert cis[1].class_number == 36